背景

近期看到企业微信告警,发现线上服务进程隔一段时间就会重启一次,遂开始进行排查。

线上服务描述

  • Python服务(使用aiohttp)
  • K8S部署
  • 容器中使用gunicorn,每个容器启动4个worker进程

排查过程

  1. 观察报警信息,只有一部分线上环境收到了影响
  2. 观察报警时间,观察到只在整点时有一次服务重启告警(告警是进程自己发送的,也就是说只有一个进程重启了),并且不是必现
  3. 观察资源信息,在服务重启前,重启信息的容器的内存有一次跳跃式增长

最终,总结出几个信息: 某一个环境(该环境数据量大),整点时分,单进程重启,内存暴涨造成的。

而服务在整点时只有一个定时任务,这个定时任务是做多数据库的数据同步。

并且,任务的调起,是由外部的一个服务向本服务发送的一个HTTP请求(只会有一个进程来处理这个HTTP请求),所以,能够说通为什么只有一个进程会重启。

  1. 观察该HTTP请求的信息,查看该请求日志中的进程ID和重启的进程ID,最后发现是一致的,并且重启时间的定时任务日志中,没有定时任务完成得日志数据,所以最终推断出进程得重启是由该定时任务导致内存暴涨,从而导致进程重启。
  2. 但,还有一个问题没有被印证,进程重启的问题不是必现的,进而,我们需要拉长时间观察,最终发现,进程会因为定时任务导致内存有一次跳跃式的增长,但不一定会导致OOM,但有一个问题,内存虽然增加了,但是并没有看到内存回收,这个需要了解一下Python的内存管理机制,是因为进程执行过程中没有一个Arena中的内存被释放完,所以就不会被回收。

解决问题

通过多进程的方式

从上文的排查过程来看,我们可以从内存回收的角度来,在每次定时任务后保证申请的内存能够还给操作系统,避免造成Python一直持有内存不归还。但直接优化GC和内存管理对我一个普通的小开发来说不太现实,所以换个角度看,进程死亡,他的内存一定会归还,那这样就可以绕开Python内存管理造成的内存不归还的问题了。所以,我尝试了以下办法:

  1. 直接在http服务进程中做fork操作,但在aiohttp中执行fork视乎会出现各种各样的小问题,例如,fork出去的进程会copy一份父进程的内存,相当于和父进程使用了同一份async loop对象,在子进程中,会报错loop是异常的报错。所以我猜测,Python异步和同步fork操作应该不同。

  2. 在GitHub中查到了一个叫aiomultiprocess的三方库解决了办法1的问题,不会报出loop异常的问题。但,我开发时是未使用gunicorn的,所以在测试环境测试时,发现fork操作其实会卡死,很是困惑,遂Google到一篇文章Gunicorn+GeventWorker环境下fork进程意外结束的问题,这篇文章的解决办法有些trick,不太想用,放弃。

  3. 既然fork两条路都走不通,再想出了一个新的方式,脚本执行。我将定时任务中的逻辑,不再放到HTTP请求里面了,而是重新修改为脚本,在HTTP调用中,调起这个脚本。最终发现,行得通,上线。

上线后,发现内存确实是能够正常回收了,内存跳跃式增长不回收,变成了一个又一个的内存尖刺。但过了几周后,又发现了服务重启,查看原因是脚本占用内存太大,导致某个服务进程在申请内存时OOM,虽然告警的频率极低,但也算是一个隐患,需要优化它。

优化代码逻辑,降低内存消耗

从上文中看到,虽然解决了内存回收的问题,但随着业务的日益增长,数据量也会增长,数据同步逻辑会随着数据量的增长导致内存增长,而影响了服务进程。但是由于某些原因,不能新开一个服务来做这个事情,所以,查看了数据同步逻辑的代码,大概逻辑是:对比一个时间段内源数据库与目标数据的数据数量,如果相等,不迁移数据,如果不相等,同步数据。但同步数据并不是按照差异同步,而是使用了源数据库单时间段内的数据全量覆盖。有些暴力了,并且单看数据数量就判断是否需要同步数据也有失偏颇。

所以,最终修改为,根据最后更新时间来对比数据,同步有差异数据。

总结

解决本次的问题,从技术上来说,学习到了:

  1. Python的内存管理
  2. 了解了Gunicorn fork进程的一些机制
  3. Python异步直接fork子进程的一些坑

从经验上看:

  1. 排查该类问题的一些思路以及逻辑。
  2. 对解决问题的思考,或者说对无法解决的问题,我如何的避开它,也就是如果规避一个问题的发生。
  3. 对数据同步,或者数据对比一些业务逻辑的思考。