美文网首页一些收藏
关于Linux Signal的问题

关于Linux Signal的问题

作者: 程序员札记 | 来源:发表于2022-05-30 07:31 被阅读0次

    在编写脚本或者程序时,我们会需要创建一些子进程或者守护进程来进行多任务处理,在很多情况下,当需要重启或者结束时需要保证子进程能够正常的结束,或者需要graceful shutdown,在进程结束前释放资源,这个时候就需要使用到Linux的信号机制了。

    Linux Signal

    1. 信号名字和编号

      对于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查看。

    1. 信号处理

      信号的处理有三种方式,分别是忽略,捕捉和默认操作。

      • 忽略信号,大多数信号都可以用这个方式来处理,但是有两种信号不能被忽略,分别是SIGKILLSIGSTOP,信号的发送者可以是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.Popenpopenargs参数内容就是java -jar ...所以真正我们需要的应用程序在守护进程的子进程中创建,这里分别为PID 4和PID 5。

    到这里可以大致分为三层,一层是PID 1,也就是主进程,第二层是watch进程,也就是PID 2和PID 3,最后一层也就是我们最主要的应用进程PID 4和PID 5。

    1. 当K8S重启或者关闭Pod的时候会发送SIGTERM信号到Container里,,可以看到在代码main函数和start_subprocess函数中都有了signal handler来处理信号,而且会把信号一路往下传递.
    2. PID 1进程接收到信号调用了os.kill(pid, signal.SIGTERM)向PID 2和PID 3发送关闭信号.
    3. 在这里,watch函数中没有对signal处理,而是使用系统的默认处理方式,同时也没有block signal,signal还会传递到PID 4和PID 5的进程中。
    4. 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内核相关,还需要更进一步的探索。

    相关文章

      网友评论

        本文标题:关于Linux Signal的问题

        本文链接:https://www.haomeiwen.com/subject/ulnnprtx.html