日前,在开源软件Easegress的运行过程中,我们团队发现了一个显著的内存占用现象:系统内嵌的etcd组件出现了内存使用量巨大且难以释放的问题。此问题虽看似偶发,实则反映了etcd在特定场景下的深层设计挑战。
我们将从Easegress为何选用etcd,以及其用户场景出发,逐步揭示导致etcd高内存占用的设计因素,并最终提出切实可行的改进建议,期望能为遇到类似问题的开发者提供更多启发。
作为我们自主研发并开源的API应用网关,Easegress远超传统反向代理功能。它集成了API编排、服务发现、弹性设计、身份认证授权等多种先进特性,旨在支持微服务、Service Mesh、Serverless等云原生架构,并能应对高并发、灰度发布、全链路压测等企业级需求。
为实现这些目标,我们早在2017年就着手重新构筑网关底层架构,放弃了在Nginx等现有网关上进行演进的思路。这一理念与Lyft开发Envoy的初衷不谋而合,但我们选择了技术门槛更低的Go语言。
Easegress的核心设计理念有三:一是无外部依赖的集群选主能力;二是类似Linux管道命令的流式插件处理机制(支持Go/WebAssembly);三是内置数据存储,用于集群控制与数据共享。
在任何分布式系统中,强大的、基于Paxos/Raft协议的自动选主机制至关重要,它确保了集群间的关键配置和共享数据同步一致。Zookeeper和etcd等组件的兴起正是基于这一需求。它们并非主要用于存储海量数据,而是为集群构建提供核心保障。
尽管Zookeeper广受欢迎,但其作为外部依赖会增加运维复杂度。最新版本的Kafka已内建选主算法,摆脱了对Zookeeper的依赖。在Go语言社区,etcd是主流选择,也是Kubernetes集群的关键组成部分。Easegress初期曾采用Gossip协议同步状态,但因其复杂且难以调试,于三年前改用内嵌etcd,此设计沿用至今。
Easegress将所有配置信息、统计监控数据乃至用户自定义数据(方便用户插件在流水线和集群内共享)存储在etcd中,极大地提升了系统的扩展性。Go语言在Google众多开源项目中的应用及在PaaS基础组件领域取代C/C++的趋势,正印证了我们对技术易扩展性的追求。
回到正题,一个Easegress用户在使用中配置了上千条处理管道(pipeline),导致其内存占用飙升至10GB以上,并且持续居高不下。具体表现为:在使用Easegress 1.4.1版本创建一个HTTP对象、包含1000个Pipeline后,系统初始化内存约为400MB;运行80分钟后增至2GB;运行200分钟后达到4GB,期间无任何请求。
我们通常建议用户通过HTTP API前缀对同类API进行分组配置,避免过多独立的pipeline。然而,这位用户选择的细粒度控制方式导致了如此庞大的pipeline数量,我们还是首次遇到。
经过深入调查,我们发现内存消耗主要源自etcd。尽管我们存储在etcd中的键值数据总量不到10MB,但令人费解的是,竟然占据了10GB的内存。这首先让我们怀疑etcd是否存在内存泄漏。在查阅etcd的GitHub议题后,发现其3.2和3.3版本确实存在已修复的内存泄漏问题,但Easegress使用的是最新的3.5版本。
而且,一般的内存泄漏问题不会引发如此巨大的内存消耗,这促使我们怀疑是否对etcd存在误用。要厘清这一点,唯有深入研究etcd的设计原理。
经过两天细致研究,我们发现了etcd中一些导致高内存消耗的设计,这些机制的成本之高令人咋舌,特此分享,以期避免更多开发者重蹈覆辙。
首先是Raft Log。etcd利用Raft Log协助Follower节点同步数据,其底层实现并非基于文件,而是纯内存。更关键的是,etcd至少会保留5000条最新的请求日志。如果键(key)的尺寸较大,这5000条日志将带来巨大的内存开销。例如,持续更新一个1MB大小的键,即使是同一键,5000条Raft Log也会占用5000MB(即5GB)的内存。这个问题在etcd的议题列表(issue #12548)中也曾被提及,但最终不了了之。值得注意的是,这个5000是一个硬编码值,无法修改(参见`DefaultSnapshotCatchUpEntries`相关源码)。
`DefaultSnapshotCatchUpEntries uint64 = 5000`
我们还发现,etcd官方团队曾将此默认值从10000降低到5000,这可能表明他们也意识到了10000对内存的巨大消耗。尽管降低了一半,但为了确保Follower节点能够跟上进度,仍保留了5000条。我们认为,这一策略仍有改进空间,至少不应将所有日志完全置于内存中。
此外,还有以下几项因素也会导致etcd内存持续增长:
索引:etcd中每个键值对都会在内存中维护一个B-tree索引。索引的开销与键的长度直接相关,并且etcd还会保存版本信息,因此B-tree的内存占用也受键长度和历史版本数量的影响。
mmap:etcd利用mmap这一Unix古老技术进行文件映射,将其底层的boltdb数据库映射到虚拟内存中。因此,数据库尺寸越大,内存占用也越大。
Watcher:大量的客户端连接和Watch数量也会显著增加内存消耗。很显然,etcd为了追求高性能而采取了这些设计。
对于用户遇到的问题,Raft Log是主要的内存消耗源。我们认为索引、mmap和Watcher并非核心问题。虽然compact和defrag(压缩和碎片整理)可以降低索引和mmap相关的内存,但在当前场景下并非主要矛盾。
具体而言,Easegress的1000多条pipeline,每条都会进行数据统计(如M1、M5、M15、P99、P90、P50等)。虽然单条统计信息约为1KB-2KB,但Easegress会将这1000多条统计数据合并写入一个键中。合并后,该键的平均尺寸可达2MB。正是这5000条内存中的Raft Log,导致etcd消耗了10GB内存。由于此前未出现如此多pipeline的场景,这个问题一直未能暴露。
最终,我们找到了一个简洁有效的解决方案:修改写入策略,不再将所有统计数据写入一个巨大的键中。我们将大的键值拆分成多个小的键来存储。虽然实际存储的数据总量不变,但每条Raft Log的数据量大幅减小。此前5000条2MB的日志会占用10GB内存,现在拆分成5000条1KB的日志,内存占用降至约500MB,从而解决了该问题。相关代码更改已提交至`PR#542`。
总结来看,要有效利用etcd,有以下最佳实践建议:
避免使用大尺寸的键和值。大键/值不仅会通过内存级的Raft Log消耗大量内存,还会增加B-tree多版本索引的内存开销。
控制数据库尺寸,并定期通过compact和defrag操作进行压缩和碎片整理,以降低内存占用。
尽量减少Watch客户端和Watch的数量,避免过大的watcher开销。
最后,尽可能使用最新版本的Go语言和etcd,这能有效规避许多内存问题。例如,Go 1.12使用了MADV_FREE内存回收机制,而1.16改为MADV_DONTNEED。前者意味着进程标记为不再使用的内存,操作系统会保留至需要更多内存时才回收,导致驻留内存(RSS)值不变;后者则会立即回收,RSS值随之变化。由于Linux下MADV_FREE在某些场景下存在问题,Go 1.16默认切换为MADV_DONTNEED。需要注意的是,etcd 3.4是使用Go 1.12编译的。
我们欢迎大家关注并支持我们的开源软件!
