标签 etcd 下的文章

作者:互联网容器团队-Chen Han、AI 研发团队 - Liu Dong Yang

在大规模GPU容器集群与模型训练场景,面临稳定性和资源利用率等多重挑战。本文展示vivo GPU平台的总体架构,介绍容器平台在大规模GPU容器集群稳定性建设措施,以及探索多种GPU容器降本提效的解决方案。分享AI工程训练平台大规模训练稳定性建设,及GPU利用率提升实践经验。

本文为2025年 vivo 开发者大会互联网技术专场分享内容之一,在微信公众号"vivo互联网技术"对话框回复【2025VDC】获取 2025VDC 互联网技术会场议题相关资料。

1分钟看图掌握核心观点👇

图1 VS 图2,您更倾向于哪张图来辅助理解全文呢?欢迎在评论区留言

一、GPU平台架构

vivo的GPU平台由物理层、容器平台层与AI工程层三方面构成。由多种GPU服务器和分布式存储以及高性能网络等基础设施,构成了可靠的物理层。容器平台层的GPU容器能力,主要包含了资源管理、编排调度、GPU虚拟化与多容器网络这四个方面。

其中资源管理,表现为多种架构资源池的部署与管理能力。编排调度能力,由GPU弹性伸缩、训推潮汐部署以及多种卡调度策略组成。自研的GPU虚拟化囊括了业界主流的MIG虚拟化、内核层虚拟化以及CUDA层虚拟化三种技术。由传统的Underlay网络以及SRIOV的RDMA直通网络,组成了丰富的容器网络架构。容器平台提供了开放的API接口,为AI工程层的训练和推理平台,提供了坚实的算力底座。通过训练和推理平台,支撑公司内的智能计算业务。

二、GPU容器能力实践

GPU容器能力实践分为两个模块,首先是大规模容器集群稳定性建设,其次是GPU容器提效降本方案。先了解下容器平台在大规模容器集群场景,如何进行稳定性建设的。

2.1 大规模容器集群稳定性

集群稳定性是一切的基石。当集群规模大时,任务多,调度频繁,导致核心组件负载激增,极易发生集群崩溃。随着节点规模扩大,运维复杂度呈指数级增长,日常运维工作繁重,发现问题不及时。同时故障处理也面临严峻挑战,故障中涉及的复杂场景多,故障处理的难度大。稳定性建设需要解决上述问题。

为了解决高频调度导致的核心组件高负载问题,我们针对Apiserver、etcd、CoreDNS,这3个核心组件进行了架构和性能优化,具体的方案如图所示。通过这些优化手段提升了组件性能,并且降低了组件负载,有利于大规模集群的平稳运行。

为了减轻集群运维负担,我们重点建设了自动化节点管理方案。把重复性的运维事项自动化。同时我们还完善了监控告警体系,开发了自动化巡检功能,使运维人员能够及时发现集群问题,快速介入,处理潜在风险,保障集群能够长久稳定运行。

故障处理是集群稳定的兜底措施,我们针对多个核心组件都做了,各类故障处理预案。结合可能存在的故障特点,构造故障场景,进行故障恢复演练,确保故障发生时,能够第一时间找到合理的解决方案,准确的处理问题。

通过上述的措施,在集群稳定性方面取得了不错的效果,首先日常的集群可用性稳定保持在99.99%水平,其次平台的年度故障复盘数相较于上一年下降60%。核心组件方面的优化也达到了不错效果,其中Apiserver的CPU负载下降70%,etcd提交延迟,从秒级缩短到毫秒级,CoreDNS的毛刺现象消失了,并且负载量下降了90%左右。

2.2 GPU容器提效降本实践

容器平台的核心竞争力之一就是助力业务提效降本,我们从不同业务维度,对GPU容器提效降本方案进行了探索。

  • 首先在单卡维度,通过自研GPU虚拟化方案,使多个容器,互不干扰的共享一张卡资源。
  • 其次是在单服务维度,使业务能够自动应对,负载变化的GPU弹性扩缩容方案。
  • 多服务维度,能够让推理服务和训练服务,分时复用整机资源的,训推潮汐部署方案。
  • 最后是在多机多卡的分布式场景中,让GPU容器搭配RDMA网络,来解决跨节点通信的瓶颈问题。

2.2.1 单卡共享-GPU虚拟化

如何让一张卡同时运行多个容器又不互相干扰,就涉及到GPU虚拟化技术。GPU虚拟化一直是AI云原生领域的热门话题之一,各大云厂商都有成熟的解决方案售卖。

vivo容器平台的自研GPU虚拟化方案,主要为了解决业务的三大痛点,

  • 首先是部分推理业务负载偏低,无法有效用满整卡资源,需要通过共享部署方式,减少资源总量,降低业务成本,提升利用率。
  • 其次是不同业务共享同一张卡时,对于安全性以及隔离性的要求各不相同,就需要使用不同的GPU虚拟化技术来满足不同业务诉求。
  • 最后在Dev开发机场景,用户使用频率偏低,需要通过显存超售,来提升资源复用率,但显存超售后又需要避免某个用户将显存耗尽,导致OOM错误影响同卡的其他用户。

自研GPU虚拟化方案包含MIG虚拟化、内核层虚拟化、CUDA层虚拟化这三种技术。结合业务场景,提供了丰富的卡调度策略,例如尽量聚集的Binpack策略、尽量分散的Spread策略,每个卡只有一个实例的CardOnlyOne策略,以及自定义节点和卡分配关系的CustomTopo策略。通过自研模块与组件,接入Kubernetes体系,对外提供统一调度能力。

首先,MIG虚拟化技术,是基于Nvidia硬件提供的,切块组合能力,能够按规则把计算单元和显存单元进行组合,组成MIG实例挂载到容器内,提供完全独立的运行环境。MIG方案的优点是拥有Nvidia官方支持,可以集成到自研体系中。由于是在硬件层面实现的算力和显存限制,所以隔离性和安全性最好。缺点就是仅支持Ampere及以后架构的部分卡,而且限定了切分比例。主要应用场景是对算力隔离有强需求的线上业务。

内核层虚拟化技术,是通过自研内核模块,创建虚拟字符设备替换原有的Nvidia字符设备,在内核态拦截IOCTL请求后,实现的算力和显存限制。优点是上层应用无感。并且内核态拥有良好的安全性。缺点是当前无开源方案,开发难度大,而且算力隔离的并不充分。主要应用场景是常规线上业务。

CUDA层虚拟化技术的原理:使用拦截库替换Nvidia Driver的原始库,建立拦截库与原始库的,API函数映射关系,从而拦截调用函数,实现算力和显存的限制。

优点是有开源方案,使用起来比较灵活。并且可以基于Nvidia提供的统一内存模型,开发显存超售能力。能够在显存不足时,使用内存替代,虽然处理速度下降,但是能够有效避免,显存OOM导致用户程序报错。

缺点是用户态导致安全性不足,并且算力隔离能力偏弱,主要应用场景是Dev开发机场景。

将自研的内核层虚拟化方案与业界方案,进行了自测性能对比,如图所示,可以看到自研方案在性能上,已经达到业界先进水平。业务使用该方案,与独占整卡部署相比较,平均单卡虚拟化率300%左右,就是把1张物理卡当3张卡使用,同时整机GPU利用率提升了30%+,成本优化超过50%。

2.2.2 服务提效-GPU弹性扩缩容

在单服务维度,如何帮助业务自动管理大量的GPU容器是提效的关键。我们引入了GPU弹性扩缩容方案。

  • 首先弹性扩缩容能力,能够快速响应负载变化,自动调节实例数量,减少人工干预次数,有利于业务在突发场景的平稳运行。
  • 其次是业务方在生产环境部署后,非生产环境的实例通常会闲置,这会浪费稀缺的GPU资源。
  • 最后由于Kubernetes原生,并不支持GPU维度的弹性扩缩容,需要寻找合适的方案来满足业务诉求。

如图所示,我们是基于开源的KEDA框架,自研了GPU-Scaler组件,使用Prometheus中存储,来自DCGM-Exporter的GPU指标,汇聚为扩缩容事件,用于触发KEDA框架,调整实例个数,以此实现了GPU弹性扩缩容能力。

由于KEDA框架支持将Workload实例数缩容到0,所以在非生产环境部署的GPU业务,默认开启无负载时,自动缩容到零的功能,以此自动回收,长期闲置的GPU资源。

最终的使用效果,线上业务资源不足类告警,下降了80%,单业务平均减少约每周1小时的,扩缩容工作量,有效降低了GPU业务的运维成本。

2.2.3 多服务降本-训推潮汐部署

在多服务维度,训练服务的资源短缺问题,与推理服务,低峰时段资源空闲问题,相对突出。考虑让训练业务利用推理的空闲资源,即训推潮汐部署方案。

首先推理和训练业务都需要稳定的运行环境。而且推理业务潮汐特征明显,夜晚负载低,资源空闲多,导致平均利用率偏低。并且多机多卡训练任务,需要整机资源,且资源需求日益增长,采购新设备,难且慢,导致训练资源缺口明显。

训推潮汐部署就是整机资源分时复用的逻辑,如图所示,推理业务在白天高负载时,稳定运行,在夜晚低峰时段,自动腾挪出空闲整机资源,借给训练业务使用。在清晨时段,训练业务结束,把整机资源还给推理业务,如此达到分时复用的效果。

如图所示。推理业务在部署前,需要评估保底负载容量。在部署时填入维持业务稳定的最少Pod数量。基于OpenKruise组件的,WorkloadSpread功能,管理不同的Subset,分别在稳定池和潮汐池中按需部署。同时配置CronHPA,定时缩容,自动调整副本数,到稳定Pod数量,优先删除潮汐池中的Pod。以此达到把潮汐池的节点整机腾空的效果。

其中我们还针对Workload的缩容优先级进行了优化。当缩容发生时,结合Pod和节点的拓扑关系,把所在节点实例数少的Pod优先缩容,达到更快的腾空效果。

通过上述方案,训推潮汐部署的降本效果明显,使推理业务,成本下降30%,同时整机GPU利用率提升20%多,有效缓解了训练资源短缺问题。

2.2.4 多机多卡提效-容器RDMA高性能网络

当前分布式训练和推理业务,对算力和显存的需求巨大,单节点资源不足,需要使用多机多卡资源,那么网络通信容易成为性能瓶颈。RDMA技术允许GPU直接访问支持RDMA设备中的数据,无需经过主机CPU或内存,实现跨节点的零拷贝数据传输,有效减少了CPU开销和网络延迟。所以从多机多卡维度,使用RDMA技术是网络提效的有效措施。从容器平台角度,GPU容器更加需要结合RDMA技术,提供简单高效的解决方案,方便业务使用。

如图所示,RDMA容器有两个网卡,一个是使用Calico-CNI插件,通过veth创建的eth0网卡,对应的是Underlay网络。另一个是使用Sriov-CNI插件,通过VF创建的eth1网卡,对应的RoCE_v2或IB协议网络。我们引入了Multus-CNI组件,能够在单容器创建时,按需调用多种CNI插件。同时我们选择使用Spiderpool组件管理IP池,以及进行IP分配和路由策略配置。

通过上述组件,在Kubernetes体系中实现了RDMA容器的全生命周期管理能力。基于容器RDMA能力,在大规模训练和推理场景,业务能够提速20%到30%之间,提升效果明显。

三、AI工程训练平台实践

3.1 训练平台整体架构

VTraining训练平台是由vivo AI计算平台团队打造的一站式大模型训练方案,它面向算法工程师,提供模型开发、模型训练和海量样本存储等能力。

VTraining底层是基于vivo容器平台、及高性能计算、网络、存储等基础设施之上构建,通过提供模型开发、模型训练、资产管理等能力,支撑公司的大模型训练业务。

像vivo手机的蓝心小V、相册等核心产品的大模型训练,都是在VTraining平台进行迭代的。

3.2 大规模训练稳定性实践

3.2.1 稳定性背景

稳定性问题是大规模训练的痛点。任务在大规模的同步训练过程中需要依赖计算、网络、存储、容器调度等复杂的基础设施环境,任何环节出问题都会导致任务中断,问题定位、恢复困难。

例如知名头部公司千亿参数大模型的大规模训练任务,平均每3小时触发一次意外中断。

3.2.2 稳定性实践-高频故障专项治理

其中一个问题,是GPU集群投入使用初期机器故障率会很高,训练任务会经常被中断。为了降低机器故障率,我们进行了高频故障专项治理。首先对GPU集群进行了大规模测试诊断、然后进行高频故障统计和修复,把有问题的软硬件进行维修、替换、升级或修复。

3.2.3 稳定性实践-故障处置流程完善

另一个问题,是任务故障就是不可避免的,所以为了缩短任务中断时间,尽快恢复任务运行,我们对故障处置流程进行了重点建设完善。

  • **训练前,**我们会针对GPU机器、网络通信等环境进行大规模检测,剔除异常节点、慢节点等问题节点,降低故障风险。
  • **训练中,**会开启自动化容错机制,以便能及时发现基础设施或任务异常,快速进行故障定位和故障容错,自动隔离故障、自动重启恢复任务运行。
  • **训练后,**对新问题进行搜集分析,完善异常特征库,增强问题诊断能力,以便后续遇到类似的故障可以快速容错。

3.2.4 稳定性实践-效果与总结

通过减少基础设施高频故障、完善任务故障处置流程两大措施,我们取得了良好效果:机器每天故障率由原来的百分之二下降到千分之一;千卡任务有效训练时长由原来的60%提升到99%,达到了行业一流水平。

同时我们也积累了相关的稳定性经验:

  • GPU集群由不稳定到稳定,需要一个软硬件磨合过程。因为不同的软硬件环境会触发不同的稳定性问题。
  • 大规模训练前,尽量剔除历史故障率高的机器。我们发现稳定的机器一般会一直很稳定,而故障率高的机器即使修复后出问题的概率也比较大。
  • 提升任务有效训练时长,需结合基础设施、训练框架、平台容错机制综合优化。例如秒级监控告警能力、checkpoint持久化策略等等都需要进行持续深入的优化。

3.3 GPU利用率提升实践

3.3.1 GPU利用率提升-业务背景及问题

GPU是稀缺资源,但是差异化的业务场景下GPU难以高效利用,利用率提升困难。比如GPU场景常见业务形态:有训练任务、推理业务、数据生产、开发调试等等类型。他们各有特点:

  • 训练任务虽然GPU利用率高,但是偶尔也会出现碎片化空闲资源,比如算法同学偶尔会将任务停下来排查问题,调下参数之类的操作,任务并不是一直不间断地在跑的。
  • 推理业务有很明显的潮汐特性,白天流量高峰期GPU利用率高、到了夜间流量低峰期利用率会比较低。
  • 数据生产任务属于离线任务,GPU利用率高、资源需求大、但难申请到资源;开发调试任务的话GPU利用率低并且要长期占用资源,导致GPU利用率长期低下。

所以不同的业务形态有不同的利用率特点,接下来介绍下我们的GPU利用率提升措施。

3.3.2 利用率提升措施一:低优任务

对于训练任务场景,偶现的碎片化空闲资源,我们 通过低优数据生产任务进行充分利用。如图所示,我们通过低优数据生产任务调度,复用训练场景偶现的碎片化资源,当正常任务需要资源时,可随时抢占低优资源,不会影响正常训练任务的调度。

3.3.3 利用率提升措施二:训推潮汐部署

对于推理业务场景,在夜间流量低峰期可以释放大量GPU资源,我们通过训推潮汐部署给离线业务复用。如图所示,通过训推潮汐部署机制,我们将夜间推理流量低峰期缩容的机器腾挪到了离线GPU资源池,给离线业务使用,白天再腾挪回在线GPU资源池进行扩容,达到潮汐复用的目的。

3.3.4 利用率提升措施三:GPU虚拟化

对于开发任务场景,长期独占GPU资源且利用率低,我们通过vivo自研VGPU虚拟化技术,减少开发任务占用的物理GPU卡数,释放冗余算力。如图所示,我们可以将单机4卡GPU机器,通过开启VGPU虚拟出16卡VGPU,相当于一卡顶四卡。

VGPU的优点是:可支持1:2、1:4超卖、还可以用内存补充显存不足,所以对用户是无感知使用的,很适用于开发任务这种对性能要求低的场景。

3.3.5 利用率提升总结与规划

总之,训练平台通过低优任务、训推潮汐部署、GPU虚拟化等策略,深度适配差异化业务场景特性,实现了资源高效复用。AI整体GPU利用率均值绝对值提升了5个百分点,接近行业一流水平。

未来训练平台也会持续对GPU利用率进行综合治理。例如进行低效任务治理、低效资源盘活、成本账单统计、奖励与惩罚措施的实施等等,让稀缺的GPU资源发挥更大价值!

四、GPU平台未来展望

在容器平台层面,我们会从多集群联邦调度、在离线GPU混部、国产异构计算芯片支持、GPU资源池化等方面能力进行综合建设,支撑上层平台业务对GPU资源的高效利用。

训练平台层面,我们会增强故障预警、任务动态容错等能力,增强业务稳定性;同时完善对大模型训练全流程能力的支撑,以及对GPU资源进行更精细化的运营,从而让GPU业务更加稳定、资源利用更加高效!

今天跟大家分享一个etcd的内存大量占用的问题,这是前段时间在我们开源软件Easegress中遇到的问题,问题是比较简单的,但是我还想把前因后果说一下,包括,为什么要用etcd,使用etcd的用户场景,包括etcd的一些导致内存占用比较大的设计,以及最后一些建议。希望这篇文章不仅仅只是让你看到了一个简单的内存问题,还能让你有更多的收获。当然,也欢迎您关注我们的开源软件,给我们一些鼓励。

为什么要用ETCD

先说一下为什么要用etcd。先从一个我们自己做的一个API网关 – Easegress(源码)说起。

Easegress 是我们开发并开源的一个API应用网关产品,这个API应用网关不仅仅只是像nginx那样用来做一个反向代理,这个网关可以做的事很多,比如:API编排、服务发现、弹力设计(熔断、限流、重试等)、认证鉴权(JWT,OAuth2,HMAC等)、同样支持各种Cloud Native的架构如:微服务架构,Service Mesh,Serverless/FaaS的集成,并可以用于扛高并发、灰度发布、全链路压力测试、物联网……等更为高级的企业级的解决方案。所以,为了达到这些目标,在2017年的时候,我们觉得在现有的网关如Nginx上是无法演进出来这样的软件的,必需重新写一个(后来其他人也应该跟我们的想法一样,所以,Lyft写了一个Envoy。只不过,Envoy是用C++写的,而我用了技术门槛更低的Go语言)

另外,Easegress最核心的设计主要有三个:

  • 一是无第三方依赖的自己选主组集群的能力
  • 二是像Linux管道命令行那样pipeline式的插件流式处理(支持Go/WebAssembly)
  • 三是内置一个Data Store用于集群控制和数据共享。

对于任何一个分布式系统,都需要有一个强一制性的基于Paxos/Raft的可以自动选主机制,并且需要在整个集群间同步一些关键的控制/配置和相关的共享数据,以保证整个集群的行为是统一一致的。如果没有这么一个东西的话,就没有办法玩分布式系统的。这就是为什么会有像Zookeeper/etcd这样的组件出现并流行的原因。注意,Zookeeper他们主要不是给你存数据的,而是给你组集群的。

Zookeeper是一个很流行的开源软件,也被用于各大公司的生产线,包括一些开源软件,比如:Kafka。但是,这会让其它软件有一个依赖,并且在运维上带来很大的复杂度。所以,Kafka在最新的版本也通过内置了选主的算法,而抛弃了外挂zookeeper的设计。Etcd是Go语言社区这边的主力,也是kubernetes组建集群的关键组件。Easegress在一开始(5年前)使用了gossip协议同步状态(当时想的过于超前,想做广域网的集群),但是后发现这个协议太过于复杂,而且很难调试,而广域网的API Gateway也没遇到相应的场景。所以,在3年前的时候,为了稳定性的考量,我们把其换成了内嵌版本的etcd,这个设计一直沿用到今天。

Easegress会把所有的配置信息都放到etcd里,还包括一些统计监控数据,以及一些用户的自定义数据(这样用户自己的plugin不但可以在一条pipeline内,还可以在整个集群内共享数据),这对于用户进行扩展来说是非常方便的。软件代码的扩展性一直是我们追求的首要目标,尤其是开源软件更要想方设法降低技术门槛让技术易扩展,这就是为什么Google的很多开源软件都会选使用Go语言的原因,也是为什么Go正在取代C/C++的做PaaS基础组件的原因。

背景问题

好了,在介绍完为什么要用etcd以后,我开始分享一个实际的问题了。我们有个用户在使用 Easegress 的时候,在Easegress内配置了上千条pipeline,导致 Easegress的内存飙升的非常厉害- 10+GB 以上,而且长时间还下不来。

用户报告的问题是——

在Easegress 1.4.1 上创建一个HTTP对象,1000个Pipeline,在Easegres初始化启动完成时的内存占用大概为400M,运行80分钟后2GB,运行200分钟后达到了4GB,这期间什么也没有干,对Easegress没有进行过一次请求。

一般来说,就算是API再多也不应该配置这么多的处理管道pipeline的,通常我们会使用HTTP API的前缀把一组属于一个类别的API配置在一个管道内是比较合理的,就像nginx下的location的配置,一般来说不会太多的。但是,在用户的这个场景下配置了上千个pipeline,我们也是头一次见,应该是用户想做更细粒度的控制。

经过调查后,我们发现内存使用基本全部来自etcd,我们实在没有想到,因为我们往etcd里放的数据也没有多少个key,感觉不会超过10M,但不知道为什么会占用了10GB的内存。这种时候,一般会怀疑etcd有内存泄漏,上etcd上的github上搜了一下,发现etcd在3.2和3.3的版本上都有内存泄露的问题,但都修改了,而 Easegress 使用的是3.5的最新版本,另外,一般来说内存泄漏的问题不会是这么大的,我们开始怀疑是我们哪里误用了etcd。要知道是否误用了etcd,那么只有一条路了,沉下心来,把etcd的设计好好地看一遍。

大概花了两天左右的时间看了一下etcd的设计,我发现了etcd有下面这些消耗内存的设计,老实说,还是非常昂贵的,这里分享出来,避免后面的同学再次掉坑。

首当其冲是——RaftLog。etcd用Raft Log,主要是用于帮助follower同步数据,这个log的底层实现不是文件,而是内存。所以,而且还至少要保留 5000 条最新的请求。如果key的size很大,这 5000条就会产生大量的内存开销。比如,不断更新一个 1M的key,哪怕是同一个key,这 5000 条Log就是 5000MB = 5GB 的内存开销。这个问题在etcd的issue列表中也有人提到过  issue #12548 ,不过,这个问题不了了之了。这个5000还是一个hardcode,无法改。(参看 DefaultSnapshotCatchUpEntries 相关源码

// DefaultSnapshotCatchUpEntries is the number of entries for a slow follower
// to catch-up after compacting the raft storage entries.
// We expect the follower has a millisecond level latency with the leader.
// The max throughput is around 10K. Keep a 5K entries is enough for helping
// follower to catch up.
DefaultSnapshotCatchUpEntries uint64 = 5000

另外,我们还发现,这个设计在历史上etcd的官方团队把这个默认值从10000降到了5000,我们估计etcd官方团队也意识到10000有点太耗内存了,所以,降了一半,但是又怕follwer同步不上,所以,保留了 5000条……(在这里,我个人感觉还有更好的方法,至少不用全放在内存里吧……)

另外还有下面几项也会导致etcd的内存会增加

  1. 索引。etcd的每一对 key-value 都会在内存中有一个 B-tree 索引。这个索引的开销跟key的长度有关,etcd还会保存版本。所以B-tree的内存跟key的长度以及历史版本号数量也有关系。
  2. mmap。还有,etcd 使用 mmap 这样上古的unix技术做文件映射,会把他的blotdb的内存map到虚拟内存中,所以,db-size越大,内存越大。
  3. Watcher。watch也会占用很大的内存,如果watch很多,连接数多,都会堆积内存。

(很明显,etcd这么做就是为了一个高性能的考虑)

Easegress中的问题更多的应该是Raft Log 的问题。后面三种问题我们觉得不会是用户这个问题的原因,对于索引和mmap,使用 etcd 的 compact 和 defreg (压缩和碎片整理应该可以降低内存,但用户那边不应该是这个问题的核心原因)。

针对用户的问题,大约有1000多条pipeline,因为Easegress会对每一条pipeline进行数据统计(如:M1, M5, M15, P99, P90, P50等这样的统计数据),统计信息可能会有1KB-2KB左右,但Easegress会把这1000条pipeline的统计数据合并起来写到一个key中,这1000多条的统计数据合并后会导致出现一个平均尺寸为2MB的key,而5000个in-memory的RaftLog导致etcd要消耗了10GB的内存。之前没有这么多的pipeline的场景,所以,这个内存问题没有暴露出来。

于是,我们最终的解决方案也很简单,我们修改我们的策略,不再写这么大的Value的数据了,虽然以前只写在一个key上,但是Key的值太大,现在把这个大Key值拆分成多个小的key来写,这样,实际保存的数据没有发生变化,但是RaftLog的每条数据量就小了,所以,以前是5000条 2M(10GB),现在是5000条 1K(500MB),就这样解决了这个问题。相关的PR在这里 PR#542

总结

要用好 etcd,有如下的实践

  • 避免大尺寸的key和value,一方面会通过一个内存级的 Raft Log 占大量内存,另一方面,B-tree的多版本索引也会因为这样耗内存。
  • 避免DB的尺寸太大,并通过 compact和defreg来压缩和碎片整理降低内存。
  • 避免大量的Watch Client 和 Watch数。这个开销也是比较大的。
  • 最后还有一个,就是尽可能使用新的版本,无论是go语言还是etcd,这样会少很多内存问题。比如:golang的这个跟LInux内核心相关的内存问题 —— golang 1.12的版sget的是 MADV_FREE 的内存回收机制,而在1.16的时候,改成了 MADV_DONTNEED ,这两者的差别是,FREE表示,虽然进程标记内存不要了,但是操作系统会保留之,直到需要更多的内存,而 DONTNEED 则是立马回收,你可以看到,在常驻内存RSS 上,前者虽然在golang的进程上回收了内存,但是RSS值不变,而后者会看到RSS直立马变化。Linux下对 MADV_FREE 的实现在某些情况下有一定的问题,所以,在go 1.16的时候,默认值改成了 MADV_DONTNEED 。而 etcd 3.4 是用 来1.12 编译的。

最后,欢迎大家关注我们的开源软件! https://github.com/megaease/ 

今天跟大家分享一个etcd的内存大量占用的问题,这是前段时间在我们开源软件Easegress中遇到的问题,问题是比较简单的,但是我还想把前因后果说一下,包括,为什么要用etcd,使用etcd的用户场景,包括etcd的一些导致内存占用比较大的设计,以及最后一些建议。希望这篇文章不仅仅只是让你看到了一个简单的内存问题,还能让你有更多的收获。当然,也欢迎您关注我们的开源软件,给我们一些鼓励。

为什么要用ETCD

先说一下为什么要用etcd。先从一个我们自己做的一个API网关 – Easegress(源码)说起。

Easegress 是我们开发并开源的一个API应用网关产品,这个API应用网关不仅仅只是像nginx那样用来做一个反向代理,这个网关可以做的事很多,比如:API编排、服务发现、弹力设计(熔断、限流、重试等)、认证鉴权(JWT,OAuth2,HMAC等)、同样支持各种Cloud Native的架构如:微服务架构,Service Mesh,Serverless/FaaS的集成,并可以用于扛高并发、灰度发布、全链路压力测试、物联网……等更为高级的企业级的解决方案。所以,为了达到这些目标,在2017年的时候,我们觉得在现有的网关如Nginx上是无法演进出来这样的软件的,必需重新写一个(后来其他人也应该跟我们的想法一样,所以,Lyft写了一个Envoy。只不过,Envoy是用C++写的,而我用了技术门槛更低的Go语言)

另外,Easegress最核心的设计主要有三个:

  • 一是无第三方依赖的自己选主组集群的能力
  • 二是像Linux管道命令行那样pipeline式的插件流式处理(支持Go/WebAssembly)
  • 三是内置一个Data Store用于集群控制和数据共享。

对于任何一个分布式系统,都需要有一个强一制性的基于Paxos/Raft的可以自动选主机制,并且需要在整个集群间同步一些关键的控制/配置和相关的共享数据,以保证整个集群的行为是统一一致的。如果没有这么一个东西的话,就没有办法玩分布式系统的。这就是为什么会有像Zookeeper/etcd这样的组件出现并流行的原因。注意,Zookeeper他们主要不是给你存数据的,而是给你组集群的。

Zookeeper是一个很流行的开源软件,也被用于各大公司的生产线,包括一些开源软件,比如:Kafka。但是,这会让其它软件有一个依赖,并且在运维上带来很大的复杂度。所以,Kafka在最新的版本也通过内置了选主的算法,而抛弃了外挂zookeeper的设计。Etcd是Go语言社区这边的主力,也是kubernetes组建集群的关键组件。Easegress在一开始(5年前)使用了gossip协议同步状态(当时想的过于超前,想做广域网的集群),但是后发现这个协议太过于复杂,而且很难调试,而广域网的API Gateway也没遇到相应的场景。所以,在3年前的时候,为了稳定性的考量,我们把其换成了内嵌版本的etcd,这个设计一直沿用到今天。

Easegress会把所有的配置信息都放到etcd里,还包括一些统计监控数据,以及一些用户的自定义数据(这样用户自己的plugin不但可以在一条pipeline内,还可以在整个集群内共享数据),这对于用户进行扩展来说是非常方便的。软件代码的扩展性一直是我们追求的首要目标,尤其是开源软件更要想方设法降低技术门槛让技术易扩展,这就是为什么Google的很多开源软件都会选使用Go语言的原因,也是为什么Go正在取代C/C++的做PaaS基础组件的原因。

背景问题

好了,在介绍完为什么要用etcd以后,我开始分享一个实际的问题了。我们有个用户在使用 Easegress 的时候,在Easegress内配置了上千条pipeline,导致 Easegress的内存飙升的非常厉害- 10+GB 以上,而且长时间还下不来。

用户报告的问题是——

在Easegress 1.4.1 上创建一个HTTP对象,1000个Pipeline,在Easegres初始化启动完成时的内存占用大概为400M,运行80分钟后2GB,运行200分钟后达到了4GB,这期间什么也没有干,对Easegress没有进行过一次请求。

一般来说,就算是API再多也不应该配置这么多的处理管道pipeline的,通常我们会使用HTTP API的前缀把一组属于一个类别的API配置在一个管道内是比较合理的,就像nginx下的location的配置,一般来说不会太多的。但是,在用户的这个场景下配置了上千个pipeline,我们也是头一次见,应该是用户想做更细粒度的控制。

经过调查后,我们发现内存使用基本全部来自etcd,我们实在没有想到,因为我们往etcd里放的数据也没有多少个key,感觉不会超过10M,但不知道为什么会占用了10GB的内存。这种时候,一般会怀疑etcd有内存泄漏,上etcd上的github上搜了一下,发现etcd在3.2和3.3的版本上都有内存泄露的问题,但都修改了,而 Easegress 使用的是3.5的最新版本,另外,一般来说内存泄漏的问题不会是这么大的,我们开始怀疑是我们哪里误用了etcd。要知道是否误用了etcd,那么只有一条路了,沉下心来,把etcd的设计好好地看一遍。

大概花了两天左右的时间看了一下etcd的设计,我发现了etcd有下面这些消耗内存的设计,老实说,还是非常昂贵的,这里分享出来,避免后面的同学再次掉坑。

首当其冲是——RaftLog。etcd用Raft Log,主要是用于帮助follower同步数据,这个log的底层实现不是文件,而是内存。所以,而且还至少要保留 5000 条最新的请求。如果key的size很大,这 5000条就会产生大量的内存开销。比如,不断更新一个 1M的key,哪怕是同一个key,这 5000 条Log就是 5000MB = 5GB 的内存开销。这个问题在etcd的issue列表中也有人提到过  issue #12548 ,不过,这个问题不了了之了。这个5000还是一个hardcode,无法改。(参看 DefaultSnapshotCatchUpEntries 相关源码

// DefaultSnapshotCatchUpEntries is the number of entries for a slow follower
// to catch-up after compacting the raft storage entries.
// We expect the follower has a millisecond level latency with the leader.
// The max throughput is around 10K. Keep a 5K entries is enough for helping
// follower to catch up.
DefaultSnapshotCatchUpEntries uint64 = 5000

另外,我们还发现,这个设计在历史上etcd的官方团队把这个默认值从10000降到了5000,我们估计etcd官方团队也意识到10000有点太耗内存了,所以,降了一半,但是又怕follwer同步不上,所以,保留了 5000条……(在这里,我个人感觉还有更好的方法,至少不用全放在内存里吧……)

另外还有下面几项也会导致etcd的内存会增加

  1. 索引。etcd的每一对 key-value 都会在内存中有一个 B-tree 索引。这个索引的开销跟key的长度有关,etcd还会保存版本。所以B-tree的内存跟key的长度以及历史版本号数量也有关系。
  2. mmap。还有,etcd 使用 mmap 这样上古的unix技术做文件映射,会把他的blotdb的内存map到虚拟内存中,所以,db-size越大,内存越大。
  3. Watcher。watch也会占用很大的内存,如果watch很多,连接数多,都会堆积内存。

(很明显,etcd这么做就是为了一个高性能的考虑)

Easegress中的问题更多的应该是Raft Log 的问题。后面三种问题我们觉得不会是用户这个问题的原因,对于索引和mmap,使用 etcd 的 compact 和 defreg (压缩和碎片整理应该可以降低内存,但用户那边不应该是这个问题的核心原因)。

针对用户的问题,大约有1000多条pipeline,因为Easegress会对每一条pipeline进行数据统计(如:M1, M5, M15, P99, P90, P50等这样的统计数据),统计信息可能会有1KB-2KB左右,但Easegress会把这1000条pipeline的统计数据合并起来写到一个key中,这1000多条的统计数据合并后会导致出现一个平均尺寸为2MB的key,而5000个in-memory的RaftLog导致etcd要消耗了10GB的内存。之前没有这么多的pipeline的场景,所以,这个内存问题没有暴露出来。

于是,我们最终的解决方案也很简单,我们修改我们的策略,不再写这么大的Value的数据了,虽然以前只写在一个key上,但是Key的值太大,现在把这个大Key值拆分成多个小的key来写,这样,实际保存的数据没有发生变化,但是RaftLog的每条数据量就小了,所以,以前是5000条 2M(10GB),现在是5000条 1K(500MB),就这样解决了这个问题。相关的PR在这里 PR#542

总结

要用好 etcd,有如下的实践

  • 避免大尺寸的key和value,一方面会通过一个内存级的 Raft Log 占大量内存,另一方面,B-tree的多版本索引也会因为这样耗内存。
  • 避免DB的尺寸太大,并通过 compact和defreg来压缩和碎片整理降低内存。
  • 避免大量的Watch Client 和 Watch数。这个开销也是比较大的。
  • 最后还有一个,就是尽可能使用新的版本,无论是go语言还是etcd,这样会少很多内存问题。比如:golang的这个跟LInux内核心相关的内存问题 —— golang 1.12的版sget的是 MADV_FREE 的内存回收机制,而在1.16的时候,改成了 MADV_DONTNEED ,这两者的差别是,FREE表示,虽然进程标记内存不要了,但是操作系统会保留之,直到需要更多的内存,而 DONTNEED 则是立马回收,你可以看到,在常驻内存RSS 上,前者虽然在golang的进程上回收了内存,但是RSS值不变,而后者会看到RSS直立马变化。Linux下对 MADV_FREE 的实现在某些情况下有一定的问题,所以,在go 1.16的时候,默认值改成了 MADV_DONTNEED 。而 etcd 3.4 是用 来1.12 编译的。

最后,欢迎大家关注我们的开源软件! https://github.com/megaease/ 

概述
使用 postgresql + etcd + patroni + haproxy + keepalived 可以实现 PG 的高可用集群,其中,以 postgresql 做数据库,Patroni 监控本地的 PostgreSQL 状态,并将本地 PostgreSQL 信息 / 状态写入 etcd 来存储集群状态,所以,patroni 与 etcd 结合可以实现数据库集群故障切换(自动或手动切换),而 haproxy 可以实现数据库读写分离 + 读负载均衡(通过不同端口实现),keepalived 实现 VIP 跳转,对 haproxy 提供了高可用,防止 haproxy 宕机。

Patroni 介绍

Patroni 是一个基于 Python 的用于实现 PostgreSQL HA 解决方案的框架。为了最大程度的兼容性,它支持多种分布式配置存储,包括 ZooKeeper、etcd、Consul 或 Kubernetes。旨在帮助数据库工程师、DBA、DevOps 工程师和 SRE 快速部署数据中心(或任何地方)的 HA PostgreSQL 环境。

当前支持的 PostgreSQL 版本从 9.3 到 16。支持自动化故障转移、物理复制和逻辑复制、提供 RESTful API 接口,允许外部应用或运维工具直接操作 PostgreSQL 集群,进行如启停、迁移等操作,与 Linux watchdog 集成,以避免脑裂现象。

项目地址: GitHub - patroni/patroni: A template for PostgreSQL High Availability with Etcd, Consul, ZooKeeper, or Kubernetes

ETCD 介绍

etcd 是一个分布式键值存储数据库,支持跨平台,拥有强大的社区。etcd 的 Raft 算法,提供了可靠的方式存储分布式集群涉及的数据。etcd 广泛应用在微服务架构和 Kubernates 集群中,不仅可以作为服务注册与发现,还可以作为键值对存储的中间件。从业务系统 Web 到 Kubernetes 集群,都可以很方便地从 etcd 中读取、写入数据。
etcd 完整的 cluster(集群)至少有三台,这样才能选举出一个 master 节点,两个 slave 节点。如果小于 3 台则无法进行选举,造成集群不可用。Etcd 使用 2379 和 2380 端口。
2379 端口:提供 HTTP API 服务,和 etcdctl 交互
2380 端口:集群中节点间通讯
项目地址:[[GitHub - etcd-io/etcd: Distributed reliable key-value store for the most critical data of a distributed system]]

环境说明

服务器信息
服务器名ip 地址os数据库读写端口只读端口组件组件端口
k8s-mater01192.168.28.11ubuntu 24.101543325433PostgreSQL,Patroni、Etcd,haproxy、keepalived8008
k8s-mater02192.168.28.12ubuntu 24.101543325433PostgreSQL,Patroni、Etcd,haproxy、keepalived8008
k8s-mater01192.168.28.13ubuntu 24.101543325433PostgreSQL,Patroni、Etcd,haproxy、keepalived8008
vip192.168.28.10PostgreSQL,Patroni、Etcd,haproxy、keepalived8008
软件信息
软件名版本
Patroni4.0.6
Etcd3.5.16
Keepalived2.3.1
Haproxy2.9.10-1ubuntu1.2
PostgreSQL16.9
watchdog5.16
python3.12.7
架构图

这个架构中,PostgreSQL 提供数据服务,Patroni 负责主从切换,etcd 提供一致性存储,HAProxy 提供访问路由,Keepalived 提供网络 VIP 高可用,Watchdog 提供节点存活及脑裂防护机制。 六者协同组成一个企业级高可用数据库集群

预先准备

网络设置
防火墙设置

关闭防火墙 (相对简单,但是不安全)
放行对应的端口(安全)

安装 PostgreSQL

通过 apt 安装 PostgreSQL (所有节点)
sudo apt install curl ca-certificates  
sudo install -d /usr/share/postgresql-common/pgdg  
sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc  
. /etc/os-release  
sudo sh -c "echo 'deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $VERSION_CODENAME-pgdg main' > /etc/apt/sources.list.d/pgdg.list" sudo apt update  
sudo apt -y install postgresql
数据库目录设置 (所有节点)
mkdir -p /data/postgresql/pgdata/
mkdir -p /data/postgresql/pg_archive/
chown -R postgres:postgres /data/postgresql/
postgres 用户设置
设置家目录
mkdir -p /home/postgres/
chown -R postgres:postgres /home/postgres/
vim /etc/passwd
#修改postgres 家目录为/home/postgres postgres:x:114:113:PostgreSQL administrator,,,:/home/postgres:/bin/bash 
设置环境变量
vim /home/postgres/.bashrc

[ -f /etc/profile ] && source /etc/profile
export PATH=/usr/lib/postgresql/16/bin:$PATH # If you want to customize your settings, # Use the file below. This is not overridden # by the RPMS.
[ -f /var/lib/pgsql/.pgsql_profile ] && source /var/lib/pgsql/.pgsql_profile
设置 sudo 免密
vim /etc/sudoers
#行末新增
postgres  ALL=(ALL)       NOPASSWD: ALL 

安装 ETCD 集群 (所有节点)

安装 ectd
下载 etcd
wget https://github.com/etcd-io/etcd/releases/download/v3.6.4/etcd-v3.6.4-linux-amd64.tar.gz

tar -xf etcd-v3.6.4-linux-amd64.tar.gz --strip-components=1 -C /usr/local/bin etcd-v3.6.4-linux-amd64/etcd etcd-v3.6.4-linux-amd64/etcdctl


mkdir -p /etc/etcd/
mkdir -p /data/etcd/

编辑 etcd 配置文件
touch /etc/etcd/etcd-pg.config.yml

#节点1配置文件
vim /etc/etcd/etcd-pg.config.yml

#节点名
name: pg-etcd01
#数据目录 
data-dir: /data/etcd
snapshot-count: 5000
#选举和心跳参数
heartbeat-interval: 100
election-timeout: 1000
#存储新能优化
quota-backend-bytes: 8589934592
max-request-bytes: 10485760
max-concurrent-requests: 5000
#自动压缩与碎片整理
auto-compaction-mode: periodic
auto-compaction-retention: "2h"
#集群通信配置
listen-peer-urls: "http://192.168.28.11:12380"
listen-client-urls: "http://192.168.28.11:12379,http://127.0.0.1:12379"
max-snapshots: 3
max-wals: 5
cors:
initial-advertise-peer-urls: "http://192.168.28.11:12380"
advertise-client-urls: "http://192.168.28.11:12379"
discovery:
discovery-fallback: 'proxy'
discovery-proxy:
discovery-srv:
initial-cluster: "pg-etcd01=http://k8s-etcd01:12380,pg-etcd02=http://k8s-etcd02:12380,pg-etcd03=http://k8s-etcd03:12380"
initial-cluster-token: 'etcd-cluster-pg'
initial-cluster-state: 'new'
strict-reconfig-check: false
enable-v2: true
enable-pprof: true
proxy: 'off'
proxy-failure-wait: 5000
proxy-refresh-interval: 30000
proxy-dial-timeout: 1000
proxy-write-timeout: 5000
proxy-read-timeout: 0


#节点2配置文件
name: pg-etcd02
data-dir: /data/etcd

snapshot-count: 5000
#选举和心跳参数
heartbeat-interval: 100
election-timeout: 1000
#存储新能优化
quota-backend-bytes: 8589934592
max-request-bytes: 10485760
max-concurrent-requests: 5000
#自动压缩与碎片整理
auto-compaction-mode: periodic
auto-compaction-retention: "2h"
#集群通信配置
listen-peer-urls: "http://192.168.28.12:12380"
listen-client-urls: "http://192.168.28.12:12379,http://127.0.0.1:12379"
max-snapshots: 3
max-wals: 5
cors:
initial-advertise-peer-urls: "http://192.168.28.12:12380"
advertise-client-urls: "http://192.168.28.12:12379"
discovery:
discovery-fallback: 'proxy'
discovery-proxy:
discovery-srv:
initial-cluster: "pg-etcd01=http://k8s-etcd01:12380,pg-etcd02=http://k8s-etcd02:12380,pg-etcd03=http://k8s-etcd03:12380"
initial-cluster-token: 'etcd-cluster-pg'
initial-cluster-state: 'new'
strict-reconfig-check: false
enable-v2: true
enable-pprof: true
proxy: 'off'
proxy-failure-wait: 5000
proxy-refresh-interval: 30000
proxy-dial-timeout: 1000
proxy-write-timeout: 5000
proxy-read-timeout: 0


#节点3配置文件
name: pg-etcd03
data-dir: /data/etcd
snapshot-count: 5000
#选举和心跳参数
heartbeat-interval: 100
election-timeout: 1000
#存储新能优化
quota-backend-bytes: 8589934592
max-request-bytes: 10485760
max-concurrent-requests: 5000
#自动压缩与碎片整理
auto-compaction-mode: periodic
auto-compaction-retention: "2h"
#集群通信配置
listen-peer-urls: "http://192.168.28.13:12380"
listen-client-urls: "http://192.168.28.13:12379,http://127.0.0.1:12379"
max-snapshots: 3
max-wals: 5
cors:
initial-advertise-peer-urls: "http://192.168.28.13:12380"
advertise-client-urls: "http://192.168.28.13:12379"
discovery:
discovery-fallback: 'proxy'
discovery-proxy:
discovery-srv:
initial-cluster: "pg-etcd01=http://k8s-etcd01:12380,pg-etcd02=http://k8s-etcd02:12380,pg-etcd03=http://k8s-etcd03:12380"
initial-cluster-token: 'etcd-cluster-pg'
initial-cluster-state: 'new'
strict-reconfig-check: false
enable-v2: true
enable-pprof: true
proxy: 'off'
proxy-failure-wait: 5000
proxy-refresh-interval: 30000
proxy-dial-timeout: 1000
proxy-write-timeout: 5000
proxy-read-timeout: 0
创建 etcd 服务
vim /etc/systemd/system/etcd-pg.service 
[Unit]
Description=Etcd Server
After=network.target
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
ExecStart=/usr/local/bin/etcd --config-file=/etc/etcd/etcd-pg.config.yml
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

启动 etcd-pg 服务

systemctl daemon-reload
systemctl start etcd-pg.service
systemctl enable etcd-pg.service
检查 etcd 集群健康状态
root@k8s-master01:~# etcdctl --endpoints="k8s-etcd01:12379,k8s-etcd02:12379,k8s-etcd03:12379" member list  -w=table

+------------------+---------+-----------+-------------------------+----------------------------+------------+
|        ID        | STATUS  |   NAME    |       PEER ADDRS        |        CLIENT ADDRS        | IS LEARNER |
+------------------+---------+-----------+-------------------------+----------------------------+------------+
| 2bb79737c88dd84d | started | pg-etcd03 | http://k8s-etcd03:12380 | http://192.168.28.13:12379 |      false |
| 354b7a6aa8551f4a | started | pg-etcd02 | http://k8s-etcd02:12380 | http://192.168.28.12:12379 |      false |
| 84101e54de967367 | started | pg-etcd01 | http://k8s-etcd01:12380 | http://192.168.28.11:12379

为了简化命令,可以通过 alisa 配置

cd ~
vim .profile
alias  etcdctlpg="etcdctl --endpoints="k8s-etcd01:12379,k8s-etcd02:12379,k8s-etcd03:12379" " source .profile

root@k8s-master01:~# etcdctlpg member list -w=table
+------------------+---------+-----------+-------------------------+----------------------------+------------+
|        ID        | STATUS  |   NAME    |       PEER ADDRS        |        CLIENT ADDRS        | IS LEARNER |
+------------------+---------+-----------+-------------------------+----------------------------+------------+
| 2bb79737c88dd84d | started | pg-etcd03 | http://k8s-etcd03:12380 | http://192.168.28.13:12379 |      false |
| 354b7a6aa8551f4a | started | pg-etcd02 | http://k8s-etcd02:12380 | http://192.168.28.12:12379 |      false |
| 84101e54de967367 | started | pg-etcd01 | http://k8s-etcd01:12380 | http://192.168.28.11:12379 |      false |

Etcd 可视化工具

安装 watchdog (所有节点)

watchdog 防止脑裂。Patroni 支持通过 Linux 的 watchdog 监视 patroni 进程的运行,当 patroni 进程无法正常往 watchdog 设备写入心跳时,由 watchdog 触发 Linux 重启。

# 安装软件,linux内置功能 sudo apt install -y watchdog
# 初始化watchdog字符设备 sudo modprobe softdog
# 修改/dev/watchdog设备权限 sudo chmod 666 /dev/watchdog
sudo chown postgres:postgres /dev/watchdog
# 启动watchdog服务 sudo systemctl start watchdog
sudo systemctl enable watchdog


安装 Patroni (所有节点)

安装
1. pip3 install --break-system-packages  psycopg2-binary
2. pip3 install --break-system-packages  patroni[etcd]
3. pip3 install --break-system-packages python-json-logger   
4. mkdir -p /etc/patroni 
创建配置文件
创建 Patroni 服务
cat /etc/systemd/system/patroni-5433.service 
[Unit]
Description=Patroni high-availability PostgreSQL
After=syslog.target network.target etcd.service
Requires=etcd-pg.service

[Service]
Type=simple
User=postgres
Group=postgres
# 使用watchdog进行服务监控 ExecStartPre=-/usr/bin/sudo /sbin/modprobe softdog ExecStartPre=-/usr/bin/sudo /bin/chown postgres /dev/watchdog
PermissionsStartOnly=true
WorkingDirectory=/home/postgres/
ExecStart=/usr/local/bin/patroni /etc/patroni/patroni-5433.yaml
ExecReload=/bin/kill -HUP 
KillMode=process
Restart=always
RestartSec=10
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

systemctl daemon-reload
配置免密登录
su postgres
cd ~
#备库从主库同步WAL日志使用,主备倒换后,主库降备库,新备库使用,所以备库也配置
touch .pgpass
vim .pgpass
192.168.28.11:5433:*:replica:replica 192.168.28.12:5433:*:replica:replica 192.168.28.13:5433:*:replica:replica 
创建 日志目录
sudo mkdir -p /var/log/patroni/
sudo chown -R postgres:postgres /var/log/patroni/
启动 Patroni

根据节点依次启动 Patroni

sudo systemctl daemon-reload
sudo systemctl restart patroni-5433.service
sudo systemctl enable patroni-5433.service
sudo systemctl status patroni-5433.service

查看服务状态,默认情况下,根据配置文件中,initdb 内容,Patroni 会自动对数据库进行初始化操作,并创建用户,配置文件,拉起数据库并建立主从关系及流复制

查看状态
root@k8s-master01:~# patronictl  -c /etc/patroni/patroni-5433.yaml list
+ Cluster: pg_patroni_etcd () ---+-----------+----+-----------+ | Member | Host               | Role    | State     | TL | Lag in MB | + | pg_patroni_5433_01 | 192.168.28.11:5433 | Leader  | running | 17 | | | pg_patroni_5433_02 | 192.168.28.12:5433 | Replica | streaming | 17 | 0 | | pg_patroni_5433_03 | 192.168.28.13:5433 | Replica | streaming | 17 | 0 | + 

通过 alisa 设置简易命令

alias  patr5433="patronictl  -c /etc/patroni/patroni-5433.yaml" 

patroni 维护命令 (所有节点)

列出节点信息
patronictl -c /etc/patroni/patroni-5433.yaml list
重做备库

reinit 先是移除了整个 data 目录。然后选择正确的节点进行备份恢复。

patronictl -c /etc/patroni/patroni-5433.yaml reinit [nodename]
查看配置
patronictl -c /etc/patroni/patroni-5433.yaml show-config
更改参数
patronictl -c /etc/patroni/patroni-5433.yaml edit-config
#重载参数
patronictl -c /etc/patroni/patroni-5433.yaml reload [nodename]
重启节点 / 关闭节点
  1. 仅重启当前节点
patronictl -c /etc/patroni/patroni-5433.yaml restart [clustername] [nodename] 
  1. 如果节点是 pending 状态的,才会执行重启操作
patronictl -c /etc/patroni/patroni-5433.yaml restart [clustername] --pending 
  1. 重启所有成员
patronictl -c /etc/patroni/patroni-5433.yaml restart [clustername]
维护模式 脱离 patroni 的集群管理
patronictl pause

patronictl pause 暂时将 Patroni 集群置于维护模式并禁用自动
在某些情况下,Patroni 需要暂时退出集群管理,同时仍然在 DCS 中保留集群状态。可能的用例是集群上不常见的活动,例如主要版本升级或损坏恢复。在这些活动期间,节点经常因为 Patroni 不知道的原因而启动和停止,有些节点甚至可以暂时提升,这违反了只运行一个主节点的假设。因此,Patroni 需要能够与正在运行的集群 “分离”,在 Pacemaker 中实现与维护模式相当的功能。

patronictl resume

patronictl resume 将使 Patroni 集群退出维护模式,并重新启用自动故障转移。
自动拉起所有数据库

switchover 主备切换
patronictl switchover
# Switchover
root@k8s-master01:~# patr5433   switchover
Current cluster topology
+ Cluster: pg_patroni_etcd (7540584003720074567) ---+-----------+----+-----------+
| Member             | Host               | Role    | State     | TL | Lag in MB |
+--------------------+--------------------+---------+-----------+----+-----------+
| pg_patroni_5433_01 | 192.168.28.11:5433 | Leader  | running   | 22 |           |
| pg_patroni_5433_02 | 192.168.28.12:5433 | Replica | streaming | 22 |         0 |
| pg_patroni_5433_03 | 192.168.28.13:5433 | Replica | streaming | 22 |         0 |
+--------------------+--------------------+---------+-----------+----+-----------+
Primary [pg_patroni_5433_01]: 
Candidate ['pg_patroni_5433_02', 'pg_patroni_5433_03'] []: pg_patroni_5433_02
When should the switchover take place (e.g. 2025-08-26T12:26 )  [now]: now
Are you sure you want to switchover cluster pg_patroni_etcd, demoting current leader pg_patroni_5433_01? [y/N]: y 
2025-08-26 11:26:32.31189 Successfully switched over to "pg_patroni_5433_02"
+ Cluster: pg_patroni_etcd (7540584003720074567) ---+---------+----+-----------+
| Member             | Host               | Role    | State   | TL | Lag in MB |
+--------------------+--------------------+---------+---------+----+-----------+
| pg_patroni_5433_01 | 192.168.28.11:5433 | Replica | stopped |    |   unknown |
| pg_patroni_5433_02 | 192.168.28.12:5433 | Leader  | running | 22 |           |
| pg_patroni_5433_03 | 192.168.28.13:5433 | Replica | running | 22 |         0 |
+--------------------+--------------------+---------+---------+----+-----------+
root@k8s-master01:~# patr5433   list
+ Cluster: pg_patroni_etcd (7540584003720074567) ---+-----------+----+-----------+
| Member             | Host               | Role    | State     | TL | Lag in MB |
+--------------------+--------------------+---------+-----------+----+-----------+
| pg_patroni_5433_01 | 192.168.28.11:5433 | Replica | streaming | 23 |         0 |
| pg_patroni_5433_02 | 192.168.28.12:5433 | Leader  | running   | 23 |           |
| pg_patroni_5433_03 | 192.168.28.13:5433 | Replica | streaming | 23 |         0 |
+--------------------+--------------------+---------+-----------+----+-----------+
接口切换
 数据库从 pg_patroni_5433_01 switchover 到 pg_patroni_5433_02
[root@pgtest1 ~]# curl -s http://192.168.28.11:8008/switchover -XPOST -d '{"leader":"pg_patroni_5433_01","candidate":"pg_patroni_5433_02"}'
Successfully switched over to "pg_patroni_5433_02" 
failover 切换

patronictl failover

# Failover
[postgres@pgtest1 ~]$ patronictl -c /etc/patroni/patroni-5433.yaml  failover
Candidate ['pg_patroni_5433_01', 'pg_patroni_5433_02','pg_patroni_5433_03'] []: pg_patroni_5433_01
Current cluster topology
... ...
Are you sure you want to failover cluster pg_cluster, demoting current master pg_patroni_5433_02? [y/N]: y
2021-10-28 03:47:56.13486 Successfully failed over to "pg_patroni_5433_01"
... ...

获取主节点 dsn 信息
root@k8s-master01:~# patronictl -c /etc/patroni/patroni-5433.yaml  dsn
host=192.168.28.12 port=5433 

安装 Haproxy (所有节点)

安装 Haproxy
sudo apt install haproxy
编辑 Haproxy 配置文件
sudo vim /etc/haproxy/haproxy.cfg 

global
 maxconn 2000
 ulimit-n 16384
 log 127.0.0.1 local0 err
 stats timeout 30s

defaults
 log global
 mode http
 option httplog
 timeout connect 5000
 timeout client 50000
 timeout server 50000
 timeout http-request 15s
 timeout http-keep-alive 15s

listen status_page
    bind *:8888
    stats enable
    stats uri /haproxy-status
    stats auth    admin:admin
    stats realm "Welcome to the haproxy load balancer status page of k8s-master"

frontend monitor-in
 bind *:33305
 mode http
 option httplog
 monitor-uri /monitor
# 主库读写端口
listen master
    bind *:15433
    mode tcp
    option tcplog
    balance roundrobin
    option httpchk OPTIONS /master
    http-check expect status 200
    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
    server pgtest1 192.168.28.11:5433 maxconn 1000 check port 8008 inter 5000 rise 2 fall 2
    server pgtest2 192.168.28.12:5433 maxconn 1000 check port 8008 inter 5000 rise 2 fall 2
    server pgtest3 192.168.28.13:5433 maxconn 1000 check port 8008 inter 5000 rise 2 fall 2

#从库读端口
listen replicas
    bind *:25433
    mode tcp
    option tcplog
    balance roundrobin
    option httpchk OPTIONS /replica
    http-check expect status 200
    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
    server pgtest1 192.168.28.11:5433 maxconn 1000 check port 8008 inter 5000 rise 2 fall 2
    server pgtest2 192.168.28.12:5433 maxconn 1000 check port 8008 inter 5000 rise 2 fall 2
    server pgtest3 192.168.28.13:5433 maxconn 1000 check port 8008 inter 5000 rise 2 fall 2
启动 haproxy
sudo systemctl enable haproxy
sudo systemctl start haproxy
HAProxy 监控页面

登录地址:[[http://192.168.28.11:8888/haproxy-status]] (也可以通过各个节点 IP + 端口登录)

默认用户密码:admin/admin

安装 Keepalived (所有节点)

安装
sudo apt install -y keepalived
配置文件
主服务配置文件
vim /etc/keepalived/keepalived.conf

global_defs {

router_
id LVS_DEVEL00

script_
user root enable_script_security } vrrp_script check_haproxy { script "/etc/keepalived/check_haproxy.sh" interval 2 weight 5 fall 3 rise 5 timeout 2 } vrrp_instance VI_1 { state Master interface ens18 virtual_router_id 80 priority 100 advert_int 1 authentication { auth_type PASS auth_pass 12345 } virtual_ipaddress { 192.168.28.10/24 } track_script { check_haproxy } }
备库节点 1 配置文件
vim /etc/keepalived/keepalived.conf
global_defs {

router_
id LVS_DEVEL01

script_
user root enable_script_security } vrrp_script check_haproxy { script "/etc/keepalived/check_haproxy.sh" interval 2 weight 5 fall 3 rise 5 timeout 2 } vrrp_instance VI_1 { state BACKUP interface ens18 virtual_router_id 80 priority 90 advert_int 1 authentication { auth_type PASS auth_pass 12345 } virtual_ipaddress { 192.168.28.10/24 } track_script {

check_
haproxy } }
备库节点 2 配置文件
vim /etc/keepalived/keepalived.conf

global_defs {

router_
id LVS_DEVEL02

script_
user root enable_script_security } vrrp_script check_haproxy { script "/etc/keepalived/check_haproxy.sh" interval 2 weight 5 fall 3 rise 5 timeout 2 } vrrp_instance VI_1 { state BACKUP interface ens18 virtual_router_id 80 priority 80 advert_int 1 authentication { auth_type PASS auth_pass 12345 } virtual_ipaddress { 192.168.24.15/24 } track_script {

check_
haproxy } }
检查脚本
vim /etc/keepalived/check_haproxy.sh

#!/bin/bash
count=`ps aux | grep -v grep | grep haproxy | wc -l`
if [ $count -eq 0 ]; then exit 1
else exit 0
fi 

赋予执行权限

chmod +x /etc/keepalived/check_haproxy.sh
依次启动
sudo systemctl start keepalived
sudo systemctl enable keepalived

📌 转载信息
原作者:
xxdtb
转载时间:
2026/1/19 17:52:08

今天跟大家分享一个etcd的内存大量占用的问题,这是前段时间在我们开源软件Easegress中遇到的问题,问题是比较简单的,但是我还想把前因后果说一下,包括,为什么要用etcd,使用etcd的用户场景,包括etcd的一些导致内存占用比较大的设计,以及最后一些建议。希望这篇文章不仅仅只是让你看到了一个简单的内存问题,还能让你有更多的收获。当然,也欢迎您关注我们的开源软件,给我们一些鼓励。

为什么要用ETCD

先说一下为什么要用etcd。先从一个我们自己做的一个API网关 – Easegress(源码)说起。

Easegress 是我们开发并开源的一个API应用网关产品,这个API应用网关不仅仅只是像nginx那样用来做一个反向代理,这个网关可以做的事很多,比如:API编排、服务发现、弹力设计(熔断、限流、重试等)、认证鉴权(JWT,OAuth2,HMAC等)、同样支持各种Cloud Native的架构如:微服务架构,Service Mesh,Serverless/FaaS的集成,并可以用于扛高并发、灰度发布、全链路压力测试、物联网……等更为高级的企业级的解决方案。所以,为了达到这些目标,在2017年的时候,我们觉得在现有的网关如Nginx上是无法演进出来这样的软件的,必需重新写一个(后来其他人也应该跟我们的想法一样,所以,Lyft写了一个Envoy。只不过,Envoy是用C++写的,而我用了技术门槛更低的Go语言)

另外,Easegress最核心的设计主要有三个:

  • 一是无第三方依赖的自己选主组集群的能力
  • 二是像Linux管道命令行那样pipeline式的插件流式处理(支持Go/WebAssembly)
  • 三是内置一个Data Store用于集群控制和数据共享。

对于任何一个分布式系统,都需要有一个强一制性的基于Paxos/Raft的可以自动选主机制,并且需要在整个集群间同步一些关键的控制/配置和相关的共享数据,以保证整个集群的行为是统一一致的。如果没有这么一个东西的话,就没有办法玩分布式系统的。这就是为什么会有像Zookeeper/etcd这样的组件出现并流行的原因。注意,Zookeeper他们主要不是给你存数据的,而是给你组集群的。

Zookeeper是一个很流行的开源软件,也被用于各大公司的生产线,包括一些开源软件,比如:Kafka。但是,这会让其它软件有一个依赖,并且在运维上带来很大的复杂度。所以,Kafka在最新的版本也通过内置了选主的算法,而抛弃了外挂zookeeper的设计。Etcd是Go语言社区这边的主力,也是kubernetes组建集群的关键组件。Easegress在一开始(5年前)使用了gossip协议同步状态(当时想的过于超前,想做广域网的集群),但是后发现这个协议太过于复杂,而且很难调试,而广域网的API Gateway也没遇到相应的场景。所以,在3年前的时候,为了稳定性的考量,我们把其换成了内嵌版本的etcd,这个设计一直沿用到今天。

Easegress会把所有的配置信息都放到etcd里,还包括一些统计监控数据,以及一些用户的自定义数据(这样用户自己的plugin不但可以在一条pipeline内,还可以在整个集群内共享数据),这对于用户进行扩展来说是非常方便的。软件代码的扩展性一直是我们追求的首要目标,尤其是开源软件更要想方设法降低技术门槛让技术易扩展,这就是为什么Google的很多开源软件都会选使用Go语言的原因,也是为什么Go正在取代C/C++的做PaaS基础组件的原因。

背景问题

好了,在介绍完为什么要用etcd以后,我开始分享一个实际的问题了。我们有个用户在使用 Easegress 的时候,在Easegress内配置了上千条pipeline,导致 Easegress的内存飙升的非常厉害- 10+GB 以上,而且长时间还下不来。

用户报告的问题是——

在Easegress 1.4.1 上创建一个HTTP对象,1000个Pipeline,在Easegres初始化启动完成时的内存占用大概为400M,运行80分钟后2GB,运行200分钟后达到了4GB,这期间什么也没有干,对Easegress没有进行过一次请求。

一般来说,就算是API再多也不应该配置这么多的处理管道pipeline的,通常我们会使用HTTP API的前缀把一组属于一个类别的API配置在一个管道内是比较合理的,就像nginx下的location的配置,一般来说不会太多的。但是,在用户的这个场景下配置了上千个pipeline,我们也是头一次见,应该是用户想做更细粒度的控制。

经过调查后,我们发现内存使用基本全部来自etcd,我们实在没有想到,因为我们往etcd里放的数据也没有多少个key,感觉不会超过10M,但不知道为什么会占用了10GB的内存。这种时候,一般会怀疑etcd有内存泄漏,上etcd上的github上搜了一下,发现etcd在3.2和3.3的版本上都有内存泄露的问题,但都修改了,而 Easegress 使用的是3.5的最新版本,另外,一般来说内存泄漏的问题不会是这么大的,我们开始怀疑是我们哪里误用了etcd。要知道是否误用了etcd,那么只有一条路了,沉下心来,把etcd的设计好好地看一遍。

大概花了两天左右的时间看了一下etcd的设计,我发现了etcd有下面这些消耗内存的设计,老实说,还是非常昂贵的,这里分享出来,避免后面的同学再次掉坑。

首当其冲是——RaftLog。etcd用Raft Log,主要是用于帮助follower同步数据,这个log的底层实现不是文件,而是内存。所以,而且还至少要保留 5000 条最新的请求。如果key的size很大,这 5000条就会产生大量的内存开销。比如,不断更新一个 1M的key,哪怕是同一个key,这 5000 条Log就是 5000MB = 5GB 的内存开销。这个问题在etcd的issue列表中也有人提到过  issue #12548 ,不过,这个问题不了了之了。这个5000还是一个hardcode,无法改。(参看 DefaultSnapshotCatchUpEntries 相关源码

// DefaultSnapshotCatchUpEntries is the number of entries for a slow follower
// to catch-up after compacting the raft storage entries.
// We expect the follower has a millisecond level latency with the leader.
// The max throughput is around 10K. Keep a 5K entries is enough for helping
// follower to catch up.
DefaultSnapshotCatchUpEntries uint64 = 5000

另外,我们还发现,这个设计在历史上etcd的官方团队把这个默认值从10000降到了5000,我们估计etcd官方团队也意识到10000有点太耗内存了,所以,降了一半,但是又怕follwer同步不上,所以,保留了 5000条……(在这里,我个人感觉还有更好的方法,至少不用全放在内存里吧……)

另外还有下面几项也会导致etcd的内存会增加

  1. 索引。etcd的每一对 key-value 都会在内存中有一个 B-tree 索引。这个索引的开销跟key的长度有关,etcd还会保存版本。所以B-tree的内存跟key的长度以及历史版本号数量也有关系。
  2. mmap。还有,etcd 使用 mmap 这样上古的unix技术做文件映射,会把他的blotdb的内存map到虚拟内存中,所以,db-size越大,内存越大。
  3. Watcher。watch也会占用很大的内存,如果watch很多,连接数多,都会堆积内存。

(很明显,etcd这么做就是为了一个高性能的考虑)

Easegress中的问题更多的应该是Raft Log 的问题。后面三种问题我们觉得不会是用户这个问题的原因,对于索引和mmap,使用 etcd 的 compact 和 defreg (压缩和碎片整理应该可以降低内存,但用户那边不应该是这个问题的核心原因)。

针对用户的问题,大约有1000多条pipeline,因为Easegress会对每一条pipeline进行数据统计(如:M1, M5, M15, P99, P90, P50等这样的统计数据),统计信息可能会有1KB-2KB左右,但Easegress会把这1000条pipeline的统计数据合并起来写到一个key中,这1000多条的统计数据合并后会导致出现一个平均尺寸为2MB的key,而5000个in-memory的RaftLog导致etcd要消耗了10GB的内存。之前没有这么多的pipeline的场景,所以,这个内存问题没有暴露出来。

于是,我们最终的解决方案也很简单,我们修改我们的策略,不再写这么大的Value的数据了,虽然以前只写在一个key上,但是Key的值太大,现在把这个大Key值拆分成多个小的key来写,这样,实际保存的数据没有发生变化,但是RaftLog的每条数据量就小了,所以,以前是5000条 2M(10GB),现在是5000条 1K(500MB),就这样解决了这个问题。相关的PR在这里 PR#542

总结

要用好 etcd,有如下的实践

  • 避免大尺寸的key和value,一方面会通过一个内存级的 Raft Log 占大量内存,另一方面,B-tree的多版本索引也会因为这样耗内存。
  • 避免DB的尺寸太大,并通过 compact和defreg来压缩和碎片整理降低内存。
  • 避免大量的Watch Client 和 Watch数。这个开销也是比较大的。
  • 最后还有一个,就是尽可能使用新的版本,无论是go语言还是etcd,这样会少很多内存问题。比如:golang的这个跟LInux内核心相关的内存问题 —— golang 1.12的版sget的是 MADV_FREE 的内存回收机制,而在1.16的时候,改成了 MADV_DONTNEED ,这两者的差别是,FREE表示,虽然进程标记内存不要了,但是操作系统会保留之,直到需要更多的内存,而 DONTNEED 则是立马回收,你可以看到,在常驻内存RSS 上,前者虽然在golang的进程上回收了内存,但是RSS值不变,而后者会看到RSS直立马变化。Linux下对 MADV_FREE 的实现在某些情况下有一定的问题,所以,在go 1.16的时候,默认值改成了 MADV_DONTNEED 。而 etcd 3.4 是用 来1.12 编译的。

最后,欢迎大家关注我们的开源软件! https://github.com/megaease/