利用 Supervisor 的 Event & Listener 监控进程并报警

接触 Supervisor 还是学 Golang 的时候,用于把 Golang 应用的非守护进程转化为守护进程。不过这次到是要把 Supervisor 用到 Java 进程上,具体的操作和 Golang 应用的进程并无差别,毕竟 Supervisor 只要求被管理的进程是非守护进程即可。

Why

公司项目里的一个 Java 工程里全是 Main-Class,其中一些需要在后台一直运行(Thrift 服务、Timer 什么的),为了保证这些服务崩溃了能自动重启,crontab 里边每分钟会去尝试运行一次(从上个项目遗留下来的做法,当然,Java 程序里会判断是不是已启动)。前几天我们的一台阿里云服务器突然 CPU 100% 挂了(巧的是,我的一台阿里云 ECS 在20分钟前也是因为该问题挂了,和公司的还是同一个区的,怀疑是不是阿里的问题),排查半天没发现异常情况,之后就重点监控 CPU 占用率比较高的进程,发现每分钟都会有 Java 进程的 CPU 突然飙到 200% 并瞬间消失。
其实这些个进程就是我们在 crontab 中每分钟 check 的那些 Main-Class,启动后发现该服务已启动后直接 exit 了(除了 check 一下并没有执行任何方法)。那为什么 CPU 占用会瞬间 200%?这是因为 JVM 在启动的时候会并连接所有除反射以外的类,而 class 文件是二进制的文件,需要从磁盘加载到内存然后解析,这种解析是很耗费 CPU 的,class 文件越多,CPU 耗费就越高。
虽然可能并不是该问题导致的服务器挂掉,但还是得优化。然后我就想到了用 Supervisor 去管理这些 Java 进程。(不用这些进程管理工具话其实也可以用 shell 脚本去 check)
配置 Supervisor 去管理这几个 Java 进程添加了几个文件就搞定了。虽然 Supervisor 能在被监控程序异常中断时自动重启之,但是还是希望程序异常时能报警通知,这就需要用到 Supervisor 的 Event 和 Listener 了。

Mechanism

Supervisor 官方对其 Event 机制的描述是:一个进程的监控/通知框架

该机制主要通过一个 event listener 订阅 event 通知实现。当被 Supervisor 管理的进程有特定行为的时候,supervisor 就会自动发出对应类型的 event。即使没有配置 listener,这些 event 也是会发的;如果配置了 listener 并监听该类型的 event,那么这个 listener 就会接收到该 event。
event listener 需要自己实现,并像 program 一样,作为 superviosr 的子进程运行。
其实被管理的进程自身也可以发 event,进而主动和 supervisor 通信,不过不想让程序依赖 supervisor,就不搞了。

Event Types

Event Type 由 supervisor 官方定义,我们是没法自己添加的,不过官方已经定义了很多类型了,够我们使用的了。
全部的 Event Type 可以参考文档:http://supervisord.org/events.html#event-types

这里以其中一个类型为例PROCESS_STATE_EXITED,顾名思义,当被管理的子进程退出的时候,就会产生该 event。(关于进程状态请参考 http://supervisord.org/subprocess.html#process-states
当 supervisord 发送一个 event 到 listener 时,会先发送一个“header”过去,类似这样

1
ver:3.0 server:supervisor serial:21 pool:listener poolserial:10 eventname:PROCESS_COMMUNICATION_STDOUT len:54

header 中每项的含义:

Key Description
var event 协议类型,目前3.0
server supervisor 的标识符,对应配置文件中[supervisord]块的 identifier
serial event 的序列号
pool listener 的 pool 的名字,如果 listener 只启动了一个进程,也就没有 pool 的概念了
poolserial eventpool 给发送到我这个 pool 过来的 event 编的号,有点绕,只要知道与上边的 serial 不同就行了
eventname event 类型名称
len header 后面的 payload 部分的长度,又称PAYLOAD_LENGTH

对于不同的 event type,header 的结构都是一样的,而 payload 的数据结果与类型相关。
PROCESS_STATE_EXITED的 payload 结构如下:

1
processname:cat groupname:cat from_state:RUNNING expected:0 pid:2766

该 payload 中每项的含义:

Key Description
processname 进程名cat [program:cat]
groupname 进程组名
from_state 进程在退出前是什么状态
expected 默认情况下exitcodes=0,2,当退出码为0或2时,是expected的,此时该值为1;其它的退出码,也就是unexpected了,该值为0
pid 退出的进程的 pid

Event Listener

编写 listener 之前,先了解一下 listener states 以及 listener 与 event 的通信协议。

Event Listener States

一个 listener 进程可能会处于以下三种状态:

Name Description
ACKNOWLEDGED 确认,相当于注册上了这个 listener
READY 就绪,event 可以被发送到这个 listener
BUSY 忙碌,event 不能被发送到这个 listener

当一个 listener 启动后,会进入ACKNOWLEDGED状态。然后它会向自己的 stdout 写一个READY\n,进入READY状态。supervisor 向READY状态的 listener 发一个 event 后,listener 进入BUSY状态。listener 返回OKFAIL后,回到ACKNOWLEDGED状态。

Event Listener Notification Protocol

  1. listener 处于READY时,当 supervisord 产生的 event 在 listener 的配置的可接受的 events 中时,supervisord 就会把该 event 发送给该 listener,并将其状态置为BUSY
  2. listener 先处理header获取len并据其读取payload,处理payload中的数据,这时候如果有相同类型的 event 产生,supersivor 会将该 event 发给相同 listener pool 中的其他 listener。
  3. listerner 处理完数据后,要向自己的 stdout 中写一条消息以告诉 supervisor 处理结果,例如RESULT 2\nOKRESULT 4\nFAIL
  4. supervisor 收到 listener 返回的结果,若为OK就认为处理成功;若为FAIL,认为 event 处理失败,会把那个 event 再次放入缓存队列并稍后再次发送。不管收到OK还是FAIL,这个 listener 都会被置为ACKNOWLEDGED状态。
  5. listener 被置为ACKNOWLEDGED状态后,这个 listener 进程可以退出并稍后自启(配置中autorestart=true的话),也可以继续运行。如果要继续运行,则它必须立即向自己的 stdout 中发送READY以让 supervisor 将其状态置为READY

Writing an Event Listener

先看一个官方的用 python 写的 demo 了解一下流程,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import sys

def write_stdout(s):
sys.stdout.write(s)
sys.stdout.flush()

def write_stderr(s):
sys.stderr.write(s)
sys.stderr.flush()

def main():
while 1:
write_stdout('READY\n') # transition from ACKNOWLEDGED to READY
line = sys.stdin.readline() # read header line from stdin
write_stderr(line) # print it out to stderr
headers = dict([ x.split(':') for x in line.split() ])
data = sys.stdin.read(int(headers['len'])) # read the event payload
write_stderr(data) # print the event payload to stderr
write_stdout('RESULT 2\nOK') # transition from READY to ACKNOWLEDGED

if __name__ == '__main__':
main()
import sys

从理论上来说,supervisor 的 event listener 可以用任何语言实现,但是 python 是实现起来比较容易的,因为有一个名为supervisor.childutils模块可以方便我们处理 event。另外 superlance 这个 package 中,有几个 event listener 的栗子,哦对了,这是个 python 的 package。既然轮子已经有了,我也就不重复造了,直接在其基础上修改了。

配置文件:

1
2
3
[eventlistener:crashmail]
command=/home/crashmail.py -o hostname -a -m your@email.com -s '/usr/sbin/sendmail -t -i -f from@email.com'
events=PROCESS_STATE_EXITED

关于 event listener 更多的配置参数就不赘述了,可以参考:[《Supervisor 基础》][5]

监控进程退出并发报警邮件的 listener:(改自 superlance 中的 crashmail.py)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
#!/usr/bin/python
# -*- coding: utf-8 -*-

# A event listener meant to be subscribed to PROCESS_STATE_CHANGE
# events. It will send mail when processes that are children of
# supervisord transition unexpectedly to the EXITED state.

import os
import socket
import sys
from supervisor import childutils


def usage():
print doc
sys.exit(255)


class CrashMail:
def __init__(self, programs, any, email, sendmail, optionalheader):

self.programs = programs
self.any = any
self.email = email
self.sendmail = sendmail
self.optionalheader = optionalheader
self.stdin = sys.stdin
self.stdout = sys.stdout
self.stderr = sys.stderr

def runforever(self, test=False):
# 死循环, 处理完 event 不退出继续处理下一个
while 1:
# 使用 self.stdin, self.stdout, self.stderr 代替 sys.* 以便单元测试
headers, payload = childutils.listener.wait(self.stdin, self.stdout)

if test:
self.stderr.write(str(headers) + '\n')
self.stderr.write(payload + '\n')
self.stderr.flush()

if not headers['eventname'] == 'PROCESS_STATE_EXITED':
# 如果不是 PROCESS_STATE_EXITED 类型的 event, 不处理, 直接向 stdout 写入"RESULT\nOK"
childutils.listener.ok(self.stdout)
continue

# 解析 payload, 这里我们只用这个 pheaders.
# pdata 在 PROCESS_LOG_STDERR 和 PROCESS_COMMUNICATION_STDOUT 等类型的 event 中才有
pheaders, pdata = childutils.eventdata(payload + '\n')

# 过滤掉 expected 的 event, 仅处理 unexpected 的
# 当 program 的退出码为对应配置中的 exitcodes 值时, expected=1; 否则为0
if int(pheaders['expected']):
childutils.listener.ok(self.stdout)
continue

hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
# 构造报警内容
msg = "Host: %s(%s)\nProcess: %s\nPID: %s\nEXITED unexpectedly from state: %s" % \
(hostname, ip, pheaders['processname'], pheaders['pid'], pheaders['from_state'])

subject = ' %s crashed at %s' % (pheaders['processname'],
childutils.get_asctime())
if self.optionalheader:
subject = '[' + self.optionalheader + ']' + subject

self.stderr.write('unexpected exit, mailing\n')
self.stderr.flush()

self.mail(self.email, subject, msg)

# 向 stdout 写入"RESULT\nOK",并进入下一次循环
childutils.listener.ok(self.stdout)

# 发送邮件, 可以用自己的, 也可以抽出来作为一个 module 复用
def mail(self, email, subject, msg):
body = 'To: %s\n' % self.email
body += 'Subject: %s\n' % subject
body += '\n'
body += msg
m = os.popen(self.sendmail, 'w')
m.write(body)
m.close()
self.stderr.write('Mailed:\n\n%s' % body)
self.mailed = body


def main(argv=sys.argv):
# 参数解析
import getopt
short_args = "hp:ao:s:m:"
long_args = [
"help",
"program=",
"any",
"optionalheader="
"sendmail_program=",
"email=",
]
arguments = argv[1:]
try:
opts, args = getopt.getopt(arguments, short_args, long_args)
except:
usage()

programs = []
any = False
sendmail = '/usr/sbin/sendmail -t -i'
email = None
optionalheader = None

for option, value in opts:

if option in ('-h', '--help'):
usage()

if option in ('-p', '--program'):
programs.append(value)

if option in ('-a', '--any'):
any = True

if option in ('-s', '--sendmail_program'):
sendmail = value

if option in ('-m', '--email'):
email = value

if option in ('-o', '--optionalheader'):
optionalheader = value

# listener 必须交由 supervisor 管理, 自己运行是不行的
if not 'SUPERVISOR_SERVER_URL' in os.environ:
sys.stderr.write('crashmail must be run as a supervisor event '
'listener\n')
sys.stderr.flush()
return

prog = CrashMail(programs, any, email, sendmail, optionalheader)
prog.runforever(test=True)


if __name__ == '__main__':
main()


# Usage
doc = """\
crashmail.py [-p processname] [-a] [-o string] [-m mail_address]
[-s sendmail] URL

Options:

-p -- specify a supervisor process_name. Send mail when this process
transitions to the EXITED state unexpectedly. If this process is
part of a group, it can be specified using the
'process_name:group_name' syntax.

-a -- Send mail when any child of the supervisord transitions
unexpectedly to the EXITED state unexpectedly. Overrides any -p
parameters passed in the same crashmail process invocation.

-o -- Specify a parameter used as a prefix in the mail subject header.

-s -- the sendmail command to use to send email
(e.g. "/usr/sbin/sendmail -t -i"). Must be a command which accepts
header and message data on stdin and sends mail. Default is
"/usr/sbin/sendmail -t -i".

-m -- specify an email address. The script will send mail to this
address when crashmail detects a process crash. If no email
address is specified, email will not be sent.

The -p option may be specified more than once, allowing for
specification of multiple processes. Specifying -a overrides any
selection of -p.

A sample invocation:

crashmail.py -p program1 -p group1:program2 -m dev@example.com

"""

坑 & 爬坑

虽然 eventlistener 在配置文件中的地位上和 program 是相同的,但是待遇那可是不一样的。在使用的过程中我就遇到了两个坑,现记录如下。

1号坑

1号坑属性:
如果修改修改一个 program 的配置,使用supervisorctl update即可载入最新的配置并按需重启对应的 program,在终端里的表现如下:

1
2
3
4
$ supervisorctl update
TestCrashMail: stopped
TestCrashMail: updated process group
$

但是对于一个 eventlistener,当修改完对应的配置后,使用supervisorctl update是毫无反应的:

1
2
$ supervisorctl update
$

restart一下这个 listener?图样图森破,startrestartstop这三个命令都是不会载入最新的配置文件的!

瞅了全部的supervisorctl的命令,发现只有一个能让 listener “满血复活”的——reload。但是这个命令是重启 supervisord 的,被其管理的所有程序当然会全部重启,如果你只有一两个程序是用 supervisord 管理的,还能忍受;但是如果像我这样有几十个程序的,显然这么做不合适。那怎么办?

爬坑攻略:
首先该方法只适用于配置分离的情况(至于把 eventlistener 直接写到 supervisord 的配置文件中是否有这个坑还不确定)。

比如我的 supervisord.conf 文件中有如下配置:

1
2
[include]
files = /opt/supervisor/conf.d/*.conf

/opt/supervisor/conf.d/文件夹下有一个 listener 的配置crashmail.conf,那么通过以下命令即可顺利爬出坑:

1
2
3
4
5
6
7
$ mv crashmail.conf crashmail.bak
$ supervisorctl update
crashmail: stopped
crashmail: removed process group
$ mv crashmail.bak crashmail.conf
$ supervisorctl update
crashmail: added process group

2号坑

坑前提示:只有始终运行的 listener 会遇到该坑;运行一次后退出并自动重启的 listener 无视此坑。

2号坑属性:
一般配置 program 的时候,我们会配置redirect_stderr=true选项以使stderr重定向到stdout中,这样可以少一个 log 文件,方便查看。
但是如果 listener 中配置redirect_stderr=true就可能会出问题——取决于你的 listener 在处理的过程中会不会向stderr中有输出。
具体现象就是该 listener 只能处理一个 event,然后就卡在那里了,如果你重启了该 listener,它还会接收到上次的那个 event,进行处理后如此循环。

爬坑攻略:
对于 eventlistener,不要配置redirect_stderr=true。supervisor 的 event 通信协议比较特殊,需要从stdout中的输出来判断 listener 的状态(详见上文中的详解),所以stderr重定向到stdout的输出可能会干扰 supervisor 对 listener 状态的判断。

2015-12-24 更新:3.2.0 版本中已经把[eventlistener:x]中的redirect_stderr=true设置禁用了,如果设置了的话会在启动 supervisord 时出错:

1
2
3
$ supervisord -c /etc/supervisord.conf
Error: [eventlistener:listener-crashmail] section sets redirect_stderr=true but this is not allowed because it will interfere with the eventlistener protocol
For help, use /usr/local/bin/supervisord -h

参考资料:
Supervisor Docs
supervisor(二)event
superlance