在编写脚本或者程序时,我们会需要创建一些子进程或者守护进程来进行多任务处理,在很多情况下,当需要重启或者结束时需要保证子进程能够正常的结束,或者需要graceful shutdown,在进程结束前释放资源,这个时候就需要使用到Linux的信号机制了。
Linux Signal
-
信号名字和编号
对于Linux来说,实际信号是软中断,许多重要的程序都需要处理信号。信号为Linux提供了一种处理异步事件的方法。比如,终端用户输入了ctrl+c来中断程序,会通过信号机制停止一个程序。每个信号都有一个对应的名字和编号,这些信号都是在Linux内核中定义的。例如SIGKILL,编号为9,也就是我们常用的
kill -9 {pid}
,这个命令会强制的结束一个进程,而上面提到的用ctrl+c则属于SIGINT,编号为2。更多的相关信号可以参考 https://www.man7.org/linux/man-pages/man7/signal.7.html ,或者通过man signal
查看。
-
信号处理
信号的处理有三种方式,分别是忽略,捕捉和默认操作。
忽略信号,大多数信号都可以用这个方式来处理,但是有两种信号不能被忽略,分别是
SIGKILL
和SIGSTOP
,信号的发送者可以是user也可以是kernel,而这两个信号则是向kernel和super user提供了进程终止和停止的可靠方法,如果忽略了,那么这个进程就变成了没人能管理的的进程,显然是内核设计者不希望看到的场景。捕捉信号,当信号来临时,用户可以根据自己的情况来处理这个信号,就是写一个处理信号的函数,然后这个函数来告诉内核该做何操作。
默认操作,对于每个信号系统都有对应默认的处理方式,当信号来临时,内核会根据默认的处理方式来处理这个信号,大多数默认的处理方式就是杀死该进程。
当然关于Linux Signal的知识不止这些,这里主要介绍一些基本的关于信号的信息,接下来介绍一下在开发中遇到一些坑。
Python处理Signal
Python中处理基础的信号还是比较简单的,使用signal.signal(signalnum, handler)
系统库函数,signalnum
为上面介绍的信号的名称,如signal.SIGTERM,signal.SIGINT,signal.SIGKILL,而handler
就是捕捉信号的函数,当信号来临时定义需要的操作。
在实际项目里,在K8S的Pod启动时会执行一个python启动脚本,这个脚本主要有两个逻辑,一个是启动应用进程,另一个就是相当于守护进程,保证之前启动的进程如果被杀死了,那么这个进程就会自动重启。
首先是主进程,这里我们以PID为代号,主进程PID为1,也就是Pod启动时会调用的脚本。实例中我们会启动两个Spring项目,可以认为一个是Web应用,一个是Admin应用,所以在脚本中,我们会分别为他们创建两个子进程,代码如下。
from multiprocessing import Process
import signal
import sys
def main():
configureLog()
p1 = Process(target=watch, args=(['java', '-jar','web.jar'], 'p1'))
p1.start()
p2 = Process(target=watch, args=(['java', '-jar','admin.jar'], 'p2'))
p2.start()
def handle_sig_term(signum, frame):
logging.info("SIGTERM {0} received, shutting down p1 and p2".format(signum))
os.kill(p1.pid, signal.SIGTERM)
os.kill(p2.pid, signal.SIGTERM)
logging.info("p1 and p2 process terminated")
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sig_term)
hold_and_wait()
在上面会有一个watch函数,这个函数的作用就是监控这个进程的状态,如果web,admin进程被杀死了,那么就会自动重启,所以在这里守护进程分别是PID 2和PID 3(hold_and_wait()
这个可以方法暂时忽略,会保持主进程继续运行)。watch的具体代码如下。
import logging
import signal
import subprocess
import sys
def watch(popenargs, process_name):
logging.info("{0} is watching...".format(process_name))
while True:
start_subprocess(popenargs, process_name)
logging.error("{0} is restarting...".format(process_name))
# 以下代码忽略,主要是执行守护任务,检测当web或者admin进程被杀死时,会自动重启
if crashed():
restart_process()
else:
hold_and_sleep()
def start_subprocess(popenargs, process_name):
logging.info("Starting subprocess {0}".format(process_name))
process = subprocess.Popen(popenargs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
def handle_signal(signum, frame):
logging.info('Caught signal: {0}, passing to app process, please wait...'.format(signum))
process.send_signal(signum)
sys.exit(0)
signal.signal(signal.SIGTERM, handle_signal)
def check_io():
while True:
output = process.stdout.readline()
if output:
logging.info(output.decode().rstrip('\n'))
else:
break
while process.poll() is None:
check_io()
logging.info(f"{process_name} process exited, which is unexpected!")
在start_subprocess()
中执行subprocess.Popen
而popenargs
参数内容就是java -jar ...
所以真正我们需要的应用程序在守护进程的子进程中创建,这里分别为PID 4和PID 5。
到这里可以大致分为三层,一层是PID 1,也就是主进程,第二层是watch进程,也就是PID 2和PID 3,最后一层也就是我们最主要的应用进程PID 4和PID 5。
- 当K8S重启或者关闭Pod的时候会发送SIGTERM信号到Container里,,可以看到在代码
main
函数和start_subprocess
函数中都有了signal handler来处理信号,而且会把信号一路往下传递. - PID 1进程接收到信号调用了
os.kill(pid, signal.SIGTERM)
向PID 2和PID 3发送关闭信号. - 在这里,
watch
函数中没有对signal处理,而是使用系统的默认处理方式,同时也没有block signal,signal还会传递到PID 4和PID 5的进程中。 - 在
start_subprocess
注册的signal handler中,把信号发送到PID 4和PID 5来关闭web和admin应用进程。
按照上面的介绍,这套流程下来应该没什么大问题,可是在部署到机器上后,行为和我们预期的并不相符,在Pod重启或者被删除的时候发现经常会有web和admin被重新启动的情况,导致Pod不能被及时关闭。本地为了复现这个问题走了些弯路,因为本地环境问题,在本地复现时使用命令sleep 30000
这样的方式来代替命令java -jar ...
和相关的守护逻辑,然后发现向主进程发送SIGTERM信号后,每次都是能完美的关闭所有进程,不会出现子进程被重启的情况。
然而问题和SIGTERM有关,SIGTERM和SIGKILL都可以停止进程,而SIGTERM 比较友好,进程能捕捉这个信号,根据需要来关闭程序。但SIGTERM同时也是比较柔和的一个信号,它可以被忽略,并不是强制信号,由于在watch
函数中没有注册handler
来处理SIGTERM信号,而是默认的传递下去,这样的方式会有被忽略的情况,也就是watch
进程PID 2和PID 3没有被杀掉,在PID 4和PID 5被杀掉的情况,守护进程PID 2和PID 3又把它们给重启了。所以在watch
函数中也要加上handler
。
def watch(popenargs, process_name):
logging.error("{0} is watching...".format(process_name))
def handle_signal(signum, frame):
logging.info('Subprocess Watch Process caught signal {0}'.format(signum))
sys.exit(0)
signal.signal(signal.SIGTERM, handle_signal)
while True:
start_subprocess(popenargs, process_name)
logging.error("{0} is restarting...".format(process_name))
结语
由于在本地模拟的时候,使用的是sleep
命令,这时进程应该是被挂起了,尽管是柔和的SIGTERM,进程也还是是被杀掉了,而实际环境中,在进行一些稍微复杂的逻辑处理,在某个环节会忽略SIGTERM信号。不过实际的原理笔者还在研究中,对于在Pod中为什么会出现忽略SIGTERM的情况,只是根据信号文档的描述和测试结果来推导的,具体什么情况会忽略什么情况会去执行SIGTERM,应该是和Linux内核相关,还需要更进一步的探索。