不少维护自建集群的同行都有过这种体验:集群规模不算大,资源水位看着也正常,可一到流量高峰期,Pod就开始以一种十分“随机”的姿态反复重建、Pending、ContainerCreating。这种时候,K8s参数优化往往才是真正要做的功课。我之前在维护一套业务链路很长的内部项目集群时,就被这种问题折腾过一轮。当时监控面板上CPU和内存都没爆,告警却一直在报Pod重启,后来排查了两天才发现,问题根本不在工作负载,而是Kubernetes自身默认参数在中小规模高密度集群里卡住了整个系统的吞吐。
这篇文章就把那轮优化的完整思路写出来。我会按“问题定位 → 控制面调优 → 节点与内核调优 → 落地验证”的顺序推进,前两部分多说“为什么”,后面几节给出可以直接对照修改的参数清单和回滚方案。内容主要面向自建集群、需要自己维护控制面的运维和平台工程师,也适合刚接手K8s集群、想搞清楚默认参数边界的人。全文不涉及具体的内部业务数据,所有数字都是我在这类场景里实测后觉得有通用参考价值的取值。
1. 为什么会想到动参数:一次调度抖动引发的两小时排查
1.1 现象与初步判断
那套集群一共十几台节点,承载的是一个多模块的业务系统,高峰期Pod总数大概在五六百个。原本一切正常,直到某天下午大多数人还在午休的时候,告警群里开始刷Pod反复重启的消息。我第一反应是看资源,先把Prometheus里的宿主机CPU、内存、磁盘IO拉出来扫了一遍,结果都很平稳,连带宽都没有明显的瓶颈。这就很奇怪了——资源没超卖,服务为什么会重启?
接着我把Pod状态翻了一遍,发现一个规律:出问题的Pod大多经历过CrashLoopBackOff,但也有不少是刚被驱逐、随后又被重新调度到别的节点。也就是说,整个事件不是单纯业务崩溃,更像是节点在主动“赶走”Pod,然后调度器又在不停地把它们搬到其他地方。我随手敲了下面这条命令,把最近一小时的所有集群事件拉出来看:
kubectl get events --all-namespaces --sort-by='.lastTimestamp' | tail -80事件列表里密密麻麻全是Evicted和FailedScheduling。其中Evicted的事件几乎都指向同一个原因——节点磁盘压力或者内存压力,但宿主机明明还有空闲。FailedScheduling则大部分不是传统的“资源不足”,而是一些非常奇怪的绑定失败。到这里我意识到,单纯看工作负载的监控数据已经不能解释现状了,问题很可能出在Kubernetes各个组件自身的运行参数上。
1.2 从日志链路上揪出根因
排查这类“组件层”的问题,最直接的办法是看各组件的日志。我当时的操作顺序是这样的:
- 先看kube-scheduler日志,确认调度器是不是积压了很多Pod没有处理。用
kubectl -n kube-system logs kube-scheduler-xxx -f滚日志,放了一会儿就看到了大量“failed to bind”的记录。 - 再看kubelet日志,通过
journalctl -u kubelet -f观察节点侧发生了什么。发现日志里频繁出现PLEG超时、Pod sandbox创建失败之类的报错。 - 最后用
curl localhost:10250/metrics之类的接口拉了点kubelet metrics,重点看了pod启动耗时和驱逐相关的计数。
三个组件的日志拼在一起之后,逻辑就清晰了。虽然宿主机资源总量够用,但kubelet的驱逐阈值设得太紧,稍微有个内存或磁盘的波动就触发了Eviction;大量Pod被驱逐后,调度器一瞬间涌入大量调度请求,又碰到了API Server的限流,产生一堆绑定失败;绑定失败的Pod继续重试,反过来让控制面压力更大。整条链路形成了一个恶性循环。
1.3 默认参数为什么偏保守
这里要插一句:Kubernetes的默认参数其实是为“通用稳定”设计的,官方默认值大多考虑到大规模集群下的容错,倾向于限制单个组件对控制面的冲击。比如API Server限流默认只有几百,是为了防止某个异常客户端把控制面打死;kubelet的驱逐阈值默认很激进,是为了保证节点始终有资源可用。
但中小规模集群有它自己的特点:节点少、Pod密度高、单台节点上的业务比较杂。这种集群里,默认参数造成的问题往往是“该放开的地方没放开,该收紧的地方反而没收紧”。所以不是默认值错了,而是它没有贴合你的实际场景。调优真正的前提是先有数据,再动参数,而不是照着某篇“生产环境最佳实践”抄一遍。
2. 控制面参数调优:API Server与调度器的关键旋钮
2.1 API Server的并发与限流
API Server是整个集群的“咽喉”,所有组件和客户端都在跟它交互。它的限流参数主要有两个:--max-requests-inflight(非变更类请求并发上限)和--max-mutating-requests-inflight(变更类请求并发上限)。默认值分别是400和200。
这个数字对一个小集群来说其实很容易被打满。当时我通过Prometheus看到了很有意思的指标:apiserver_request_total{code="429"}的在事件发生时段一直有增长。429就是“Too Many Requests”,说明API Server开始拒绝一部分请求了。被拒绝的请求会不断重试,又加剧了拥塞。
我参考的调整方式是:
| 参数 | 默认值 | 我的参考调整 | 适用场景说明 |
|---|---|---|---|
--max-requests-inflight | 400 | 800 | Pod数量多、ListWatch频率高,或集群有大量短生命周期任务的场景 |
--max-mutating-requests-inflight | 200 | 400 | 大量Pod被同时重建、批量更新Deployment时更明显 |
--watch-cache-size | 100(每个watch类型缓存容量) | 200 | 有大量自定义控制器或组件频繁ListWatch时提升命中率 |
有一点必须提醒:并发数不是越大越好。限流的本质是把压力挡在API Server外面,如果你一口气调到几千,一旦出现真正的异常流量,API Server会直接把压力全部吸收,最后连kube-apiserver的healthz都拉不起来。我建议每次在默认值基础上加一倍,并且配合压测观察CPU和内存涨幅。另外一个容易被忽略的是watch cache,如果你的集群里有很多控制器在频繁List同一类资源,调高--watch-cache-size能明显降低API Server的重复计算压力。如果你用的是kubeadm部署的静态Pod,修改/etc/kubernetes/manifests/kube-apiserver.yaml后API Server会自动重启。
2.2 kube-scheduler调度器的关键旋钮
调度器比API Server更容易被忽视,因为它平时看起来“工作得很安静”。其实调度器的QPS限制非常影响集群吞吐。--kube-api-qps默认是50,--kube-api-burst默认是100。理解一下这两个值的含义:QPS是持续速率,Burst是一次性突发允许的量。当数百个Pod同时变成Pending状态,调度器需要批量发出“绑定Pod”的请求,此时默认的QPS和Burst根本不够用,调度队列里会积压大量待处理的Pod。
我遇到的情况里,最明显的一个数字是kube_scheduler_pending_pods在事件发生时一度涨到两百多。把QPS调整为100、Burst调整为150之后,Pending数量很快就降下来了。
这里还有一个容易踩坑的参数:--failed-bindings-retry-interval,默认2秒。如果调度失败,Pod会坐在队列里等2秒再重试;这个值看似合理,但如果集群里存在大量调度失败的情况,重试风暴本身也会成为问题。我当时没有调这个值,因为更根本的问题是资源碎片——很多Pod不是没有节点可调度,而是单节点剩余资源不够“塞”下它。如果你发现自己集群里频繁出现“Insufficient cpu”或者“Insufficient memory”,但整个集群的空闲资源又很多,先把目光放到资源预留和装箱策略上,不要盲目调重试间隔。
2.3 controller-manager的隐匿参数
kube-controller-manager里的参数平时不太被提起,但对节点故障的响应速度影响极大。关键的三个是:
--node-monitor-grace-period:节点心跳中断多久后标记为Unknown,默认40秒。--node-monitor-period:节点状态巡检周期,默认5秒。--pod-eviction-timeout:标记Unknown后多久开始驱逐Pod,默认5分钟。
这三个参数组合起来,意味着一个节点彻底失联后,最快要经过大约5分多钟才开始驱逐上面的Pod。对于很多内部业务系统来说,这个速度是可以接受的;但如果你的业务有快速摘除故障节点流量的诉求,默认值就显得太慢了。我当时把--pod-eviction-timeout调到了3分钟,同时把--node-monitor-grace-period保持默认,因为有不少节点还是机械盘,心跳延迟偏高,调太短容易误判。这里想提醒的是:缩短驱逐超时可以不改监控宽限期,二者分开调会更保险。
另一个值得关注的是--terminated-pod-gc-threshold,默认12500。它的意思是终止状态的Pod堆积超过12500个才触发GC。中小集群的Pod基数小,很难触发到这个阈值,于是大量已经结束的Pod就留在etcd里。我建议在资源密集的集群里把它调到1000~2000左右,减少etcd里的无效数据。
3. 节点侧参数调优:kubelet与内核的协同配置
3.1 kubelet的镜像GC与驱逐策略
kubelet是每个节点上的管家,它有两个参数组直接决定Pod的“生死”:镜像GC和驱逐策略。
先说镜像GC。默认的--image-gc-high-threshold=85和--image-gc-low-threshold=80含义是磁盘使用率到85%时开始清理镜像,清理到80%停止。听起来很合理,但在小集群里,这个默认值会频繁和部署高峰期撞车。举个例子:你正在滚动更新一个服务,需要拉取新版本镜像,磁盘占用率刚好超过85%,kubelet就会一边清理旧镜像一边拉新镜像,磁盘IO压力陡增,Pod启动速度大幅下降。
我当时把高阈值调到了90、低阈值调到85,并且关注了一个容易搞混的点:一定要区分nodefs和imagefs。如果镜像数据和容器可写层在同一个分区,调高阈值前必须先确认这块分区的真实容量。别把阈值调上去了,结果GC不触发,最后把磁盘写满,那是更大的事故。磁盘空间充足的机器上,高阈值调到90是没问题的;如果容量本来就紧张,就不要动95之类的高位数值了。
驱逐策略方面,默认的eviction thresholds是这样的意思:memory.available低于100Mi时开始驱逐。100Mi看起来是给系统留了100Mi内存,但对生产节点来说实在太危险了。当内存只剩100Mi时,内核OOM killer可能已经先一步把进程杀了,kubelet再介入已经晚了。我参考的配置里,--eviction-hard设成了类似这样的一组值:
--eviction-hard=memory.available<2Gi,nodefs.available<10%,nodefs.inodesFree<5%具体数字要根据机器内存大小调整,一般来说取总内存的4%~5%作为一个“缓冲阈值”比较合理。此外,生产环境建议开启--eviction-soft和--eviction-soft-grace-period,给Pod一点自行释放资源的时间,而不是直接触发强驱逐。
再说--max-pods。默认值是110,很多刚接触K8s的同行会想“那我把它调到200,节点利用率不是更高吗?”——千万别这么干。max-pods不只是给kubelet看的,它还会影响Pod CIDR的分配、CNI的IP池大小、节点上路由表容量。在大多数自建集群里,单节点跑到三五十个Pod已经是合理的密度上限了。我当时把max-pods从110调成了60,反而比之前更稳定。原因是之前调度器会把Pod尽量往少数节点上堆,多到一定程度,CNI插件开始报IP不足,Pod虽然创建了但网络一直没就绪,kubelet只能反复重试。
3.2 内核参数:inotify、max_map_count与somaxconn
节点层的参数不止kubelet,操作系统内核参数也很关键。三个参数在K8s环境里最“出镜率高”:
fs.inotify.max_user_watches:默认值通常是8192或65536,取决于发行版。如果节点上跑了很多Java应用、配置同步工具或者文件监听类的服务,很容易把这个值耗尽。现象就是容器里不管做什么文件操作都报“Too many open files”或者inotify相关的错误,但进程的fd数明明很低。我把它调到524288,写进/etc/sysctl.d/99-k8s.conf。vm.max_map_count:默认65530。JVM和许多内存分配密集型的程序会大量创建内存映射区,这个值不够的话,Java服务会直接崩,报“max map count”相关错误。ES类应用一般建议至少262144,我习惯直接设成1048576。net.core.somaxconn:默认通常是4096或128。这个参数控制socket监听队列长度,在连接突发时如果队列打满,客户端会直接收到Connection refused。对很多内部系统来说,128完全不够。我设置的参考值是65535。
这三个参数的修改都可以直接通过sysctl命令临时生效,但为了重启后也能保持,强烈建议统一写进sysctl配置文件。写完后执行sysctl --system加载,不需要重启节点。注意一点:有些sysctl参数对容器内是否生效取决于运行时和Pod的sysctl安全设置,如果你修改完发现容器里没有变化,可能需要在节点上重启对应Pod甚至重启节点才能让已有容器继承新值。
3.3 系统预留资源:给kubelet和容器运行时留条命
这个参数组我单独说,是因为它对节点稳定性的影响非常大,但排查时最不容易联想到。kubelet有两个预留参数:--system-reserved和--kube-reserved。它们的含义是给操作系统和Kubernetes组件预留一部分CPU和内存,不让业务Pod把这些资源全部抢走。
如果你的节点完全不配置这两个参数,极端情况下业务Pod把内存用满,OOM killer开始“选人”杀掉进程时,kubelet、container runtime甚至sshd都有可能在候选名单里。一旦kubelet被杀,整个节点就会进入NotReady状态,然后节点上的Pod被全部驱逐——这是连锁反应里最严重的一种。我在调优时给每台节点设置的大致参考量是:
--system-reserved=cpu=500m,memory=1Gi --kube-reserved=cpu=500m,memory=1Gi对于4核8G的节点,这样剩下大约6G给业务Pod;如果是更大规格的节点,可以适当按比例上调。不要想着“每个Pod多分一点”,而是先想清楚“出了问题的兜底是什么”。
4. 调优如何落地:基线采集、灰度与回滚
4.1 先建一套观测基线
调优最忌讳的事情是“凭感觉改完,感觉好了就完事”。我当时养成了一个习惯:动手之前,先花一两天把现有集群的指标基线采集好。需要关注的核心指标有这么几类:
- API Server层面:每秒请求量、429响应数量、watch请求的延迟。
- 调度器层面:调度队列长度、调度端到端耗时、绑定请求失败次数。
- kubelet层面:驱逐事件数量、PLEG重新同步次数、Pod启动耗时。
- 节点层面:内存可用率、磁盘可用率、内核inotify当前使用量、socket队列相关指标。
Prometheus搭配kube-state-metrics和node-exporter基本都能拿到这些数据。如果没有完整的监控体系,也可以先靠kubectl get events和日志做定性的判断,但效率会低很多。我建议至少要把事件数量做成一个环比曲线,这样“调之前每小时产生多少事件、调之后每小时产生多少事件”就非常直观。
4.2 参数变更的具体操作路径
控制面组件如果走的是kubeadm静态Pod方式,修改参数就是编辑/etc/kubernetes/manifests/下的yaml文件,API Server会在文件变更后自动重启。这个机制方便,但也有一个隐患:如果当时正好有大量Pod在调度,重启瞬间会造成短暂的拒绝请求。所以操作窗口尽量选在业务低峰。
节点侧,如果是systemd管理kubelet,我习惯用drop-in文件,而不是直接改主unit文件。这样既保留了原生配置,又能让我们的自定义参数集中在一个文件里,后续版本升级时也容易排查:
[Service] Environment="KUBELET_EXTRA_ARGS=--max-pods=60 --image-gc-high-threshold=90 --image-gc-low-threshold=85 --eviction-hard=memory.available<2Gi,nodefs.available<10%"改完执行systemctl daemon-reload && systemctl restart kubelet。注意restart kubelet本身会短暂中断节点上的Pod生命周期管理,如果节点上有大量正在启动的Pod,要预留几秒的观察时间。
4.3 灰度与回滚预案
有一个原则我在这轮调优中体会很深:每次只改一类参数,类别之间隔开足够长的观察窗口。
我当时给自己定的节奏是:先改API Server限流,观察一天;再改调度器QPS,观察两天;接着改一个节点的kubelet和内核参数,观察三天;最后才把节点参数批量推广到全集群。这样做的好处是,一旦某个环节出了新问题,能准确知道是哪次变更引入的。比如我调完API Server限流的第二天,发现某个控制器的同步延迟反而变高了,马上就能定位到是watch cache相关的问题,迅速回滚了一部分,没有牵连其他配置。
回滚的方法其实很简单:控制面组件直接恢复原有yaml文件,等待自动重启;节点kubelet删除drop-in文件,重新reload并且restart;内核参数备份旧值,修改后如果发现问题就执行sysctl -w恢复原值并同步修改配置文件。真正需要注意的不是“怎么恢复”,而是“怎么及时发现问题”。我建议在变更高峰期前把告警阈值临时调低一档,比如原来内存剩余低于1G才告警,变更期间改成剩余低于2G就告警,这样提前知道节点是否进入了危险区间。
5. 调优后的实测数据与几个常见误区
5.1 调优前后的数据对比
这一轮调整做完大概一周,我从监控平台导出一组前后对比数据。为了不涉及具体业务量,我把数字做了归一化处理,但对比例的参考价值很直观:
| 指标 | 调优前 | 调优后 | 变化 |
|---|---|---|---|
| 调度端到端耗时P95 | 1.8s | 280ms | 下降约84% |
| API Server 429响应次数(每小时) | 几十次 | 0 | 清零 |
| 节点驱逐事件(每周) | 5~8次 | 0 | 清零 |
| Pod启动成功率 | 90% | 99%+ | 显著提升 |
| 终止Pod堆积数 | 数千 | 数百 | etcd压力明显下降 |
这里面要说明一下:调度端到端耗时的下降,主要贡献来自API Server限流和scheduler QPS的提升;驱逐事件清零则要归功于eviction硬阈值和系统预留资源的调整;Pod启动成功率提升有一部分是镜像GC阈值改动带来的。
5.2 常见误区:三个我亲眼见过的“翻车”操作
第一,盲目调高max-pods。之前有个同行把节点max-pods调到200,结果节点上的CNI网段不够分配,Pod创建出来都拿不到IP,最后只能全部重建。K8s的容量规划是有木桶效应的,max-pods只是其中一块木板。
第二,只调kubelet不调内核。如果你把所有Pod生命周期相关的参数都调好了,但宿主机inotify上限就是不够,服务进程还是会出问题。这种问题症状不明显——Pod一直Running,健康检查也通过,但业务就是报错。我在那轮调优里差点漏掉这个点,后来是看到Java应用日志里频繁出现“Too many open files”才追查进来。
第三,不做基线直接套模板。网上流传的各种“生产最佳实践参数”本身没有错,错在直接照搬。不同集群的节点规格、业务类型、网络插件、磁盘类型差异很大,适合别人的阈值不一定适合你。参数优化这事,不在于是不是最佳,而在于你清不清楚自己在调什么、为什么调。
5.3 版本升级时参数默认值的变化
最后再说一个很容易被忽视的点:K8s大版本升级时,参数默认值可能悄悄变化。有些参数会被改名、废弃,有些feature gate会改变默认开关状态。比如某些版本把API Server的--address参数调整为只监听localhost,很多老旧的启动脚本升级后直接无法启动。
我现在的做法是:把整套自定义参数做成一个配置文件模板,放进版本管理。升级前先diff一下模板与新版默认参数的差异,逐个确认需要保留还是调整。不要指望升级之后现有参数还百分百有效,尤其是像eviction-hard这类跟节点稳定性强相关的设置,值得每升一次级就重新审视一遍。
在这方面踩过几次坑之后,我的体会是:K8s参数优化本质上和业务代码优化没有区别,核心是靠数据说话。现象、假设、变更、验证,每一步都要能追得回去,才叫“实战”,不然只是在盲调。另外一个我认为很有价值的习惯是:每次改参数前打印当前值、改完后打印新值,连同变更原因一起存文件。这会让后续的排障和复盘效率提升非常多。如果刚接手K8s集群,建议先用默认参数跑一段时间,让监控数据告诉你问题在哪,再动手改——这比从任何“最佳实践”文章里抄一堆参数靠谱得多。