Python SOA

一般地讲, 一个公司的后端技术体系在随着公司的业务量增长的时候, 会经历三个阶段的架构演变.

  1. 一个包罗万象的大应用
  2. 渐渐开始有业务拆分, 数据拆分, 服务化
  3. 服务治理, 微服务化, 运维体系化

饿了么也不是例外. 但是本文讲的并不是我们如何演进, 或则是我们如何克服了业务改造的困难. 而是从一个大量使用 Python 做服务化的角度, 给大家分享一下在使用这一门语言做服务化的时候的问题.

语言

和其他大部分使用 java 来做服务化的公司不同, 饿了么的大部分主营业务都是用 Python 写的. 所以我们并没有特别多的可参考对象. 很多问题都是一个一个躺过来的. 相对于 java 而言, Python 有以下优点:

  1. 语言抽象程度高, 开发效率高
  2. 语言简单易学
  3. 丰富的第三方库

也有很多缺点:

  1. GIL: 一个进程只能使用单核 CPU, 导致 Python 必须使用多进程来利用多核资源
  2. 过于灵活的语法, 使对开发的要求更高

RPC

协议选取

协议选取是大应用拆分做服务化的第一步. 我们在从单一 php 应用拆解到 Python 服务化的过程中, 选择了 thrift 协议. 主要原因是为了更好的多语言支持和高效的二进制协议.

但 thrift 作为一个通用协议, 它并没有提供除了远程调用以外更复杂的功能. 比如类比 http 协议中的 request header 和 response header. 因为这些功能在原生协议中的缺失, 如果想要传递一些调用的上下文信息就非常困难. 同时 thrift 的官方 python 包的实现虽然能用, 但并不符合 Pythoner 的审美.

所以上规模以后, 我们决定下手改造了 thrift 协议.

thriftpy

thriftpy 是我们开源的一个完整 thrift python 实现. 主要特点是完全遵守 thrift 协议规则, 并且去掉了事前生成代码的使用方式, 改为实时解析 thrift 文件, 生成对应 python 对象的方法. 同时为了保证性能, 协议解析等关键组件使用了 cython 的方式编写.

同时开源的, 还有我们对 thrift 协议改造的部分, 放在 contrib/tracking 目录中. 以下是我们改造的内容:

1. 协议握手

改造协议第一点是要向后兼容, 必须得保证旧的客户端(官方版, 或老的定制版)依然能够顺利运行. 我们选择了在连接建立时, 通过握手协商的方式解决兼容性问题:

在连接刚刚建立的时候, client 会向 server 调用一个特殊命名的方法, 并传递两个参数:

server 则会返回自己的协议版本. Client 根据这个版本号对之后的调用做响应的兼容处理.

例如我们现在线上有两个自定义的协议版本: v1 和 v2. 还包括很少的一些原生 thrift 客户端. 在一个连接建立的时候会有以下几种可能

客户端版本(下)\服务端版本(右) native v1 v2
native 普通调用 客户端没有调用握手接口, 服务端使用原生协议 客户端没有调用握手接口, 服务端使用原生协议
v1 握手调用失败, 客户端使用原生协议 均为 v1 版本. 服务调用会包含 request header 服务端发现客户端为 v1, 仅处理 request header, 不返回 response header
v2 握手调用失败, 客户端使用原生协议 服务端返回自己版本为 v1, 客户端仅发送 request header 均为 v2, 调用会发送 request header, 返回会发送 response header
2. 调用协议变更

在经过 1. 协议握手 后, 我们会有以下行为:

  1. 如果支持 request header, 则在发送真正的 thrift request 之前, 会发送一个 thrift struct 到服务端. 这个 struct 中包括:

    name type comment
    request id string 唯一标定当前请求链
    rpc id string 标定本次请求在请求链中的位置. 定义为: 其中 i 本次调用是当前接口处理流程中第几次 rpc 访问
    meta map<string, string> 原信息. 可以传递当前处理的分区信息等
  2. 如果支持 response header, 我们会在返回处理结果之前返回一个 thrift struct. 内容是一个 map<string, string> 具体行为由业务决定.

Server

Python 因为 GIL 的原因, 不得不使用多进程的方式提升 CPU 利用率. 但随之而来的是如何管理服务进程. 我们参考了 gunicorn, 实现了一个 prefork 的 python thrift server: gunicorn_thrift

gunicorn

gunicorn 是一个 pre fork 的 wsgi server. 它会在 master 进程建立 listen 端口, 然后再多次 fork 出 worker 进程. worker 进程会负责 accept 新的连接请求, 并调用对应业务代码对它进行处理.

gunicorn@eleme

gunicorn 作为 wsgi server, 主要负责处理短连接请求. 而 rpc 连接是流量很大的长连接, 于是在这种状态下会有以下一些问题.

问题1: worker 上连接数量不均匀

在饿了么, 我们在使用一个修改版本的 gunicorn. 在压测的时候, 我们发现原有的 gunicorn 在处理长连接的时候会有 worker 负载分布不均匀的问题, 有的 worker 的 CPU 负载明显比其他 worker 高很多. 这个造成了服务器的 CPU 利用率偏低. 我们的 fork 中, 修改了 gunicorn 的工作模型. master 不再负责建立 socket 连接, 而是由 worker 中利用 SO_REUESEPORT 参数来建立多个 listening 端口.

使用了 SO_REUSEPORT 之后, 内核在分配一个连接建立请求的时候会平均地交给不同进程. 解决了多进程 accept 连接数不均的问题. 参见 lwn 的报道.

我们的 patch 在 eleme/gunicorn.

问题2: 进程启动时数量较多的超时

我们发现, 即使将 gunicorn 启动方式改为先 fork, 再使用 reuseport 启动监听端口. 在服务重启时, 依然会有很多的接口调用超时. 超时原因从之前的建立连接超时, 变为了等待资源初始化. Python 程序启动后, 载入业务代码, 初始化连接池等操作较重, 加上是多进程模型, 每个进程都需要完成初始化后才能对外服务. 所以在启动的时刻, 系统资源从磁盘 IO 到 CPU 都比较吃紧. 如果这时正好有连接进来, 在这个连接的请求会变现得非常慢.

完全消除这个问题比较困难, 但以下的操作能够较大缓解服务重启时的波动:

  1. 先加载再开监听端口
  2. 多数进程均启动后再注册服务
问题3: 柔性关闭

gunicorn 的柔性关闭机制是停止 listening 端口, 然后等待当前请求处理完成或则超时后退出 worker. 对于 thrift 长连接服务来说, 一个 rpc 请求完成以后并不会关闭连接, 所以如果完全使用 gunicorn 的柔性关闭方式, 很有可能会在一个请求还在被处理的时候, worker 被强制退出, 造成数据的不一致问题. 所以得另外实现长连接的关闭机制, 我们的做法如下:

  1. 关闭 listening 端口, 并将这个节点拉出服务节点列表.
  2. 健康检查接口以抛出服务即将关闭 异常的方式告知客户端连接已失效, 并同时关闭此连接.
  3. 如果有正在处理的请求, 等待特定的时间. 超时后, 直接杀掉处理流程.
  4. worker 退出
问题4: 异步 IO: Gevent

我们的线上服务使用 gevent 来让所有的同步过程异步化. gevent 能够非常明显地提高每个进程的吞吐量, 同时保持业务代码依然使用同步形式开发的风格.

但是但是但是

monkeypatch 是一个非常高魔的方式, 虽然使用非常方便, 却会引入非常多的不确定因素. 我们内部将 gevent 的问题归类为三种:

  1. 引入自己维护事件循环的库, 导致 gevent 阻塞.
  2. 因为 gevent 的存在导致的不可预料的异常流程.
  3. 因为抢占式调度, 计时打点会不准确.

例如:

类似的问题很多很多, 所以到框架后期, 每引入一个第三方库我们评估的第一件事情就是这个库的 gevent 兼容性. 但依然会碰到和 gevent 相关的奇怪的场景.

查这些问题的工具主要是 stracetcpdump.

Client

在服务化的后端架构里面, 客户端需要有以下几个功能:

  1. 连接管理: 对单个 rpc 连接的管理.
  2. 节点管理: 对单个服务的后端, 不同后端节点的管理.

连接管理 (thrift_connector)

对于 thrift 来说, 因为调用频率非常高, 所以最佳实践是在进程内做一个统一的长连接管理, 省掉大量的新建连接操作. 实现一个连接池有个非常好的参考: apache pool.

thrift_connector 本身是个很简单的连接池, 目标是封装 rpc 连接池最基本的一些接口. 例如 rpc 调用接口, 连接回收, 连接池重置, 健康检查等. 而相对复杂的 SOA 治理功能, 则在这个库上层再做包装. 特别要说明的是连接健康检查, 一般有两种做法:

  1. 定期对连接池中的控线连接做一次健康检查
  2. 在从连接池中取出连接的时候做一次健康检查

thrift_connector 中两种实现均有, 但是为了 100% 保证取出的连接是可用的, 我们选择的健康检查方式是 2.

节点管理

在服务治理中, 一个服务的节点地址是由服务注册中心提供的. 但是对于每个客户端, 拿到服务节点列表后还需要做一些管理控制:

  1. 软负载: 将请求平均分配到各个节点
    我们的做法是: 抽取所有已有连接数低于当前节点平均连接数的后端节点, 然后随机从这个列表中选取一个后端节点尝试连接, 直到有一个后端节点连接上为止. 如果所有节点都无法连接, 则再从整个后端列表中遍历节点进行连接.

  2. 节点状态维护: 检测后端各个节点的健康状况 我们使用 被动做后端节点健康检查 + 主动探活的方式. 在新建连接的时候, 记录连接失败的节点, 在这个节点访问失败超过一定次数后, 从后端节点移除, 并添加到一个故障节点的列表中. 然后我们定期对这个列表中的服务节点做探活检查.

在 Python 多进程, 多实例的场景下, 这种暴力的做法效果还不错.

服务治理

服务化后, 服务的数量和服务的节点的数量就会非常多, 以至于不能够再以手工的方式来管理服务和节点. 同时业务分散后, 失败和超时再所难免, 故我们也需要一些自动或手工的容灾方案.

存储选择: zookeeper

最初调研阶段的时候, 我们尝试过 etcd 作为核心数据的存储中间件. 但是因为当时 Python 的 binding 还不支持它最新的接口. 我们最终选用了更稳定的 zookeeper.

但我们并没有把握能够完全控制住这个中间件, 同时也还需要从功能上做二次封装. 我们对 Python 的 zookeeper binding Kazoo 做了二次封装.

zookeeper 功能封装

我们在 zookeeper 中主要存放了以下数据:

  1. 服务注册中心: 用于服务注册发现
  2. 服务配置: 用于中心化管理服务配置信息
  3. 服务开关: 用于动态变更服务状态, 例如降级开关等

每个应用应当会有一些全局相同的配置和在特殊环境下的差异性配置. 所以我们在设计的时候, 将每个配置项按照两个层次来存储:

  1. app id(应用名)
  2. cluster(集群)

一个应用会首先读取制定的 cluster 的配置(差异性配置), 然后才会读取全局的应用配置. 这样就可以给不同集群的相同应用配置不同的配置.

zookeeper 容灾封装

zookeeper 中的数据对于整个服务系统来讲是一个强依赖. 如果这个体系发生问题, 整个线上系统都会瘫痪. 为了提高我们应用的容错性, 在我们自己的封装中做了好几层容错:

  1. 内存缓存(性能+容错): zookeeper 的数据是直接存在内存中的, 依赖 zookeeper 的事件机制来做更新. 业务使用的时候只从内存中读取.
  2. 磁盘缓存(容错): 在磁盘中存放配置信息.

这样在 zookeeper 失效的时候, 正在运行的应用不会 crash, 同时因为有磁盘缓存, 服务也能正常重启. 有了这个机制后我们才敢放心把整个体系推进下去.

zookeeper 性能优化

这里还是要拿 Python 多进程的问题说. 进程一多, 每个进程都会有一个对 zookeeper 的连接, 所以 zookeeper 的压力非常大. zookeeper 自己有一个 observer 的方式来提升它的吞吐量. 不过我们还尝试了下面一个办法. 在 worker 都刚刚建立成功的时候, 通过系统文件锁, 只允许一个 worker 对 zookeeper 建立连接并获取应用配置. 但因为配置会落地到磁盘, 其他 worker 就监控这个文件的修改事件. 在文件发生修改的时候, 再读取一次文件中的配置.

服务注册

为了解决手工维护服务节点的问题, SOA 需要一个服务注册中心, 让服务自己上报自己的连接地址. 对于 Python, 注册的问题是在哪注册. gunicorn 的模型中, master 是启动 worker 和控制 worker 数量的核心进程, 为了保证它的简单可靠, 我们早期即决定不在 master 中引入 zookeeper 等复杂的逻辑. 而如果我们将注册放到 worker 进程中执行, 每个 worker 都会有大量的重复写入. 所以我们做出了下面的方案:

  1. 在 master 启动时, 专门 fork 出一个进程用于往注册中心注册.
  2. fork 出的进程首先会定时轮询服务的健康检查端口. 在健康检查通过后再注册.
  3. 退出时, 这个进程会负责将节点从注册中心拉出.

接口控制

熔断 doctor

熔断是在接口或则服务在检测到不健康的状态下, 主动拒绝服务的机制. 在做这个机制之前, 首先需要收集应用本身的一些调用指标. 对 Python 来讲, 因为多进程, 各个进程的指标独立, 所以数据的采样率很低, 直接在进程内部做的话效果不会特别好. 我们的做法是再增加一个进程, 然后每个 worker 会定时上报 + 拉取全局的异常指标, 和本地的指标做合并后作为熔断的判断依据.

开关

利用 zookeeper 实时推送的开关. 我们用这个做服务降级, 功能开关等等.

权限

有了自定义的 thrift 协议后, 我们就能够根据调用方做访问控制了. 黑白名单这种东西可以很简单地在服务端控制.

我们现在的系统还没有做访问频次控制, 主要原因还是因为线上结构是分布式+多进程的, 要做到精确控制的话, 必须得有一个第3方的计数器中间件才行, 但是这样会增加一次远端调用, 并不划算.

监控

饿了么一共有三个大的监控体系, 分别对应调用追踪, 业务指标, 服务器指标. 三个系统相辅相成, 能够帮助运维很快地定位问题. 我们正在努力的方向是融合这3个系统, 更加提升问题定位速度和易用性.

业务指标

业务指标我们使用 c-statsd-proxy + statsd + graphite + grafana 组合. 自动报警使用自研的 banshee, 能够使用 3 sigma 智能探测指标的异常波动.

这套系统在现在每分钟处理550万指标. 但这个系统因为多 idc 支持不够, 以及没有 tag 功能. 我们正在考虑慢慢淘汰.

日志

在日志管理上, 我们选取的是 rsyslog 作为日志收集器. 主要利用了 rsyslog 能够根据 logger name 和 logger level 将不同的 日志写入到不同的文件中去.

我们运维部门则方便使用这个规则, 将需要的日志收集到 elk 系统中, 方便做集中查询.

Last thing

Python 最大的优点在写着开心, 就算我们躺过了非常多的坑, 但还是开心. 程序员嘛, 最重要的就是写代码开心了…

wooparadog

ಠ_ಠ