前提概要

信号概念

进程间通信

  • socket
  • 消息队列
  • 管道:类似瀑布开发模式
  • 共享内存
  • 信号量:一般和共享内存一起使用
  • 信号:应急事件、通知

信号,一般是异常情况下的工作模式(其他几种通信方式是常规情况下的),是进程间通信唯一一种异步通信方式,即可以在任何时候发送信号给一个进程。

为了响应各种各样的事件,定义了下面64种信号:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[root@whatfuck ~]# kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

对收到的信号有三种处理方式

  1. 默认动作

    term,终止进程;core,终止进程后,使用core dump将当前进程运行状态保存至文件

  2. 捕捉

    自定义信号处理函数,信号递达时执行相应函数

  3. 忽略

    不希望处理某些信号的时候,可以忽略此信号。其中SIGKILL/SEGSTOP两个信号是应用程序无法捕捉和忽略的,不然操作系统都没权限管理了

 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
[root@whatfuck ~]yum install -y man man-pages
[root@whatfuck ~]# man 7 signal
       ...
       # 默认行为
       Term   Default action is to terminate the process.
       Ign    Default action is to ignore the signal.
       Core   Default action is to terminate the process and dump core (see core(5)).
       Stop   Default action is to stop the process.
       Cont   Default action is to continue the process if it is currently stopped.
       ...
       Signal     Value     Action   Comment
       ──────────────────────────────────────────────────────────────────────
*      SIGHUP        1       Term    Hangup detected on controlling terminal
                                     or death of controlling process # 挂起进程
*      SIGINT        2       Term    Interrupt from keyboard # 终止进程ctrl+c
*      SIGQUIT       3       Core    Quit from keyboard # 停止进程退出ctrl+\
       SIGILL        4       Core    Illegal Instruction # 非法指令
       SIGABRT       6       Core    Abort signal from abort(3)
       SIGFPE        8       Core    Floating point exception
*      SIGKILL       9       Term    Kill signal # 无条件终止
       SIGSEGV      11       Core    Invalid memory reference
       SIGPIPE      13       Term    Broken pipe: write to pipe with no
                                     readers # 管道坏了
       SIGALRM      14       Term    Timer signal from alarm(2)
*      SIGTERM      15       Term    Termination signal # 尽可能终止
*      SIGUSR1   30,10,16    Term    User-defined signal 1
       SIGUSR2   31,12,17    Term    User-defined signal 2
       SIGCHLD   20,17,18    Ign     Child stopped or terminated
       SIGCONT   19,18,25    Cont    Continue if stopped #继续运行停止的进程
       SIGSTOP   17,19,23    Stop    Stop process #无条件停止,但不是终止
*      SIGTSTP   18,20,24    Stop    Stop typed at terminal#停止或暂停,不是终止
       SIGTTIN   21,21,26    Stop    Terminal input for background process
       SIGTTOU   22,22,27    Stop    Terminal output for background process
       ...

上表中第一列是各信号的宏定义名称,第二列是各信号的编号,第三列是默认处理动作,Term表示终止当前进程,Core表示终止当前进程并且Core Dump,Ign表示忽略该信号,Stop表示停止当前进程,Cont表示继续执行先前停止的进程。

通信过程来看:

  1. 是注册信号处理函数

    注册函数的系统调用有signal或sigaction函数,将方法和信号关联起来,进程遇到对应信号就执行此方法

  2. 发送信号

    发送信号(硬件方式和软件方式):

    • Ctrl+C: SIGINT 终止;Ctrl+Z: SIGTSTP 停止(硬件);Ctrl-\产生SIGQUIT信号
    • 进程访问了非法内存,内存管理模块MMU就会产生异常,然后把信号 SIGSEGV 发送给进程;除0的指令:CPU产生SIGFPE信号给进程 (硬件)
    • 当子进程退出时,我们要给父进程发送 SIG_CHLD 信号
    • 向读端已关闭的管道写数据时产生 SIGPIPE 信号
    • kill等命令执行系统调用

    进程或者shell发送信号有四个函数可调用kill/tkill/tgkill/rt_sigqueueinfo,最终调用do_send_sig_info函数,将信号放在对应进程的task_struct的信号数据结构中,信号注册有个判断条件:

    • 小于32的信号存于集合中,集合中已注册此信号不会再次注册,因此可能会丢失,称为不可靠信号
    • 大于32的存于链表上,可以重复注册,是可靠信号,不会丢失。ulimit -a中的pending signals即是队列长度
  3. 处理信号

    信号产生(Generation)后,实际执行信号的处理动作称为信号递达(Delivery),信号从产生到递达之间的状态,称为信号未决(Pending)。

    每个信号都有两个标志位分别表示阻塞和未决,和一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。进程可以选择阻塞(Block)某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

    信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:

    1. 用户程序注册了SIGQUIT信号的处理函数sighandler
    2. 当前正在执行main函数,这时发生中断或异常切换到内核态。
    3. 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
    4. 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandlermain函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
    5. sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
    6. 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

    内核处理进程收到的signal是在当前进程的上下文,故进程必须是Running状态。当进程唤醒或者调度后获取到CPU,则在从内核态转到用户态时检测是否有signal等待处理。

信号示例

  1. Core动作:

     ```bash
     # 默认关闭,`ulimit -c 1024`设置当前shell进程core file size为1024K
     (test) [root@whatfuck test]# ulimit -c
     0
     (test) [root@whatfuck test]# ll
     总用量 16
     -rw-r--r-- 1 root root 138 6月  28 17:06 Pipfile
     -rw-r--r-- 1 root root 718 6月  28 17:20 process_test.py
     -rwxr-xr-x 1 root root  49 6月  28 16:09 test_signal.sh
     -rwxr-xr-x 1 root root  90 6月  28 18:24 test_zombie.sh
     (test) [root@whatfuck test]# ulimit -c 1024
     (test) [root@whatfuck test]# ulimit -c
     1024
     (test) [root@whatfuck test]# ./test_signal.sh &
     [1] 19756
     (test) [root@whatfuck test]# kill 19756
     (test) [root@whatfuck test]# 
     [1]+  已终止               ./test_signal.sh
     (test) [root@whatfuck test]# 
     (test) [root@whatfuck test]# ll
     总用量 16
     -rw-r--r-- 1 root root 138 6月  28 17:06 Pipfile
     -rw-r--r-- 1 root root 718 6月  28 17:20 process_test.py
     -rwxr-xr-x 1 root root  49 6月  28 16:09 test_signal.sh
     -rwxr-xr-x 1 root root  90 6月  28 18:24 test_zombie.sh
     (test) [root@whatfuck test]# ./test_signal.sh &
     [1] 19800
     (test) [root@whatfuck test]# kill -11 19800
     (test) [root@whatfuck test]# 
     [1]+  段错误               (吐核)./test_signal.sh
     (test) [root@whatfuck test]# 
     (test) [root@whatfuck test]# ll
     总用量 268
     -rw------- 1 root root 479232 6月  28 19:07 core.19800
     -rw-r--r-- 1 root root    138 6月  28 17:06 Pipfile
     -rw-r--r-- 1 root root    718 6月  28 17:20 process_test.py
     -rwxr-xr-x 1 root root     49 6月  28 16:09 test_signal.sh
     -rwxr-xr-x 1 root root     90 6月  28 18:24 test_zombie.sh
     (test) [root@whatfuck test]# 
     (test) [root@whatfuck test]# gdb -c core.19800 bash --quiet
     Reading symbols from /usr/bin/bash...Reading symbols from /usr/lib/debug/usr/bin/bash.debug...done.
     done.
    
     warning: core file may not match specified executable file.
     [New LWP 19800]
     Core was generated by `/bin/bash ./test_signal.sh'.
     Program terminated with signal 11, Segmentation fault.
     #0  0x00007fc04c2c14fc in __libc_waitpid (pid=pid@entry=-1, stat_loc=stat_loc@entry=0x7ffd1f5e3830, options=options@entry=0)
         at ../sysdeps/unix/sysv/linux/waitpid.c:31
     31            return INLINE_SYSCALL (wait4, 4, pid, stat_loc, options, NULL);
     (gdb) 
     ```
    
  2. nginx进程控制

    参考 http://nginx.org/en/docs/control.html和https://zhuanlan.zhihu.com/p/31512482

僵尸进程

什么情况下产生僵尸进程?

进程的终止过程:

进程执行终止动作时,会关闭所有文件描述符,释放在用户空间分配的内存,但它的退出信息还在进程表PCB中保留着:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用waitwaitpid获取这些信息,然后再彻底清除掉这个进程。举个栗子,一个进程的退出状态可以在Shell中用特殊变量$?查看,因为Shell是它的父进程,当它终止时Shell调用waitwaitpid得到它的退出状态同时彻底清除掉这个进程。

如果一个进程已经终止,但是它的父进程尚未调用waitwaitpid对它进行清理,这时的进程状态称为僵尸(Zombie)进程。任何进程在刚终止时都是僵尸进程,它是一个正常的中间过程,僵尸进程正常情况下立刻被父进程清理。

🧟‍♀️进程不占用内存和cpu资源,但是进程表是有限资源,进程表满了那么其他进程当然就没法启动了。

🧟‍♀️进程是不能用kill命令清除掉的,因为kill命令只是用来终止进程的,而僵尸进程已经处于终止状态了。

如何处理🧟‍♂️进程?

  1. kill杀死元凶父进程(一般不用),使得僵尸进程进程变成孤儿进程,由init充当父进程,并回收资源

  2. 父进程用wait或waitpid去回收资源(方案不太好,是个阻塞操作)

  3. SIGCHLD信号机制,在处理函数中调用wait回收资源

    子进程在终止时操作系统会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

    僵尸进程kill -9是没用的,因为它本身已经执行完了,处于dead状态了

🧟‍♀️进程模拟:

1
2
3
(sleep 1 & echo zombie pid $!; exec /bin/sleep 60)
# check zombie pid and ppid
ps axo pid,ppid,comm | grep <zombie pid>

孤儿进程

下面情况就是孤儿进程:

  1. 父进程终止
  2. 子进程还存在(这些子进程仍在运行或者已经是僵尸进程了)

这些子进程会立刻被init进程收养,孤儿进程仍然使用它能使用的所有资源。PID 1有一个独特的职责,就是收割孤儿进程,孤儿进程退出后,由init负责调用wait()

了解下init(systemed)进程,它是一个由内核启动的用户级进程。在centos7中,grub2引导内核被载入内存得到系统控制权,并初始化所有设备驱动和数据结构等,之后就会启动一个用户级进程systemed,由systemd完成后续的进程启动,如终端等。init进程是其他进程的祖先,pstree -lps命令查看进程树。

孤儿进程模拟:

1
2
3
4
5
# this subshell spawn a new subshell sleep run on backgrund
# and print the pid then die immediately
(sleep 10 & echo orphan pid $!)
# check pid and ppid
ps axo pid,ppid,comm | grep <orphan pid>

容器停止

要考虑的问题:

  1. 应用进程优雅关闭
  2. 孤儿/🧟‍♀️进程问题

容器关闭

docker容器停止命令:docker stop [container]

默认情况下,向容器中的主进程ID为1发送一个SIGTERM信号,超时时间10s,超时后,发送SIGKILL信号相当于docker kill(两者发送的信号类型和超时时间都可以使用相应参数更改默认行为),因此,有下面两点需要我们考虑:

  • 容器应用要能够正确接收到信号

  • 容器应用要能够正确的响应对应的信号才能优雅的关闭

    此处大家可以分析下tomcat、dubbo、rocketmq对kill -15信号的是怎么处理的?

容器中pid为1的主进程如何接收信号?写Dockerfile构建容器镜像时,容器中的进程运行命令CMD和ENTRYPOINT有两种写法,分别是shell和exec格式,shell格式的ENTRYPOINTCMD/bin/sh -c的子进程,即应用主进程ID不是1,而是sh,应用收不到docker发来的signals

示例1,能收到信号:

1
2
3
#!/usr/bin/env bash
trap 'exit 0' SIGTERM
while true; do :; done
1
2
3
4
# dockerfile exec format -- test:1
FROM ubuntu:trusty
COPY loop.sh /
ENTRYPOINT ["/loop.sh"]
1
2
3
4
5
6
7
docker build -t test:1 -f dockerfile1 .
docker run -it --name test1 test:1
docker inspect test1 --format '{{.Config.Entrypoint}}'
docker exec -it test1 ps aux
# 此处注意容器停止时间及退出状态码
docker stop test1
docker inspect -f '{{.State.ExitCode}}' test1

示例2,不能收到信号:

1
2
3
4
# dockerfile shell format -- test:2
FROM ubuntu:trusty
COPY loop.sh /
ENTRYPOINT /loop.sh
1
2
3
4
5
6
docker build -t test:2 -f dockerfile2 .
docker run -it --name test2 test:2
docker inspect test2 --format '{{.Config.Entrypoint}}'
docker exec -it test2 ps aux
# 此处注意容器停止时间及退出状态码
docker stop test2

一般我们用一个脚本启动程序时,需要确保最终执行的命令是exec或者gosu,即使用新的进程替代原有的进程,保持 PID 不变,不产生新的进程,bash脚本默认是在子shell中执行的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/usr/bin/env bash
set -e
if [ "$1" = 'postgres' ]; then
    chown -R postgres "$PGDATA"
    if [ -z "$(ls -A "$PGDATA")" ]; then
        gosu postgres initdb
    fi
    exec gosu postgres "$@"
fi
exec "$@"

如果在停止容器时需要进行一些额外清理工作,与其他容器通信或者协调多个进程,这时需要确保ENTRYPOINT脚本能接收到信号执行相应的动作或者传递给其他进程,如官方示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/sh
# 使用trap自行捕获信号以便手动执行某些操作
trap "echo TRAPed signal" HUP INT QUIT TERM

# start service in background here
/usr/sbin/apachectl start

echo "[hit enter key to exit] or run 'docker stop <container>'"
read

# 此处添加其他清理动作即可
echo "stopping apache"
/usr/sbin/apachectl stop

echo "exited $0"

以上就是docker容器中的进程接收信号和处理信号需要注意的点。

僵尸进程问题

以jenkins为例,类似Jenkins这种应用,🧟‍♀️进程不可避免的,因为Jenkins代码通常不是由Jenkins维护者编写的,比如我们自己的Jenkins构建脚本。这种情况下就非常有必要去避免出现很多🧟‍♀️进程的现象。

BASH同样可以收割僵尸进程,但是它默认不会转发信号,除非自己写代码实现,另外bash默认忽略SIGQUIT(3)和SIGTERM(15)信号。

tini通过信号转发解决了这个问题,发向tini的信号,tini也会向子进程发送同样的信号。Jenkins-Dockerfile

tini最主要的两个功能:

  1. 收割僵尸进程
  2. 信号转发

POD停止

通常在应用发布更,从应用停止到新版本启动恢复过程中,应保证客户无感知,传统的解决方式是通过将应用更新流程划分为手工摘流量、停应用、更新重启三个步骤,由人工操作实现客户端无对更新感知。这种方式简单而有效,但是限制较多:不仅需要使用借助网关的支持来摘流量,还需要在停应用前人工判断来保证在途请求已经处理完毕。这种需要人工介入的方式运维复杂度较高,只能适用规模较小的应用,无法在大规模系统上使用。

在k8s中,pod是k8s中创建和管理的、最小的可部署计算单元,pod代表的是集群上处于运行状态的一组容器,这些容器共享linux名称空间、控制组、以及怎样运行这些容器的声明等。

pod的停止设计目标是,令你能够请求删除进程,并且知道进程何时被终止,同时也能够确保删除操作最终完成。集群会记录跟踪pod的优雅停止,而不是直接强制干掉pod。

pod本身就是容器组,与容器停止基本一致,另外多了一个preStop钩子及移除端点的动作。

过程:

  1. API、kubectl等手动请求删除某个特定的pod,默认该pod优雅停止时间30秒
  2. APIServer中的该pod对象被更新,并记录它的最终死期,此时该pod对象的状态会更新为Terminating,kubelet监听到此状态变化后开始本地pod关闭过程:
    1. 若有定义preStop回调,kubelet变开始在容器内执行该回调逻辑。如果死期到了,preStop回调还在执行,那么kubelet还会再宽限2秒的时间(这种情况需要修改terminationGracePeriodSeconds属性)。
    2. kubelet触发容器运行时发送sigterm信号给pod中每个容器的主进程1(多个容器收到的信号顺序不确定的,顺序问题可以考虑preStop处理)
  3. 与此同时,控制平面将pod从对应的端点列表移除(service、切片等)
  4. 死期到了后,kubelet强制关闭容器,向pod中的所有容器发送sigkill信号
  5. APIServer上直接删除pod对象,那么就再没法从任何客户端看到此pod了