为什么无法用SIGTERM终止容器1号进程

2023-08-15 08:12:46 浏览数 (2)

kubernetes官网资料介绍在停止一个pod时会先发送SIGTERM给Pod各个容器的1号进程实现优雅退出,实际使用容器时会有用户没有关注到如果容器1号进程执行的程序或者脚本如果缺少注册SIGTERM信号handler会导致容器无法优雅退出,直到terminationGracePeriodSeconds时间到达后发送SIGKILL强制杀掉尚未退出的容器。这篇文章从内核实现机制分析为什么容器1号进程不注册SIGTERM信号handler会导致无法优雅停止容器。

为了模拟这个过程进行如下操作:

代码语言:javascript复制
使用如下bash脚本作为容器的1号进程启动,脚本通过参数0和1控制脚本启动时是否注册SIGTERM信号handler:
# cat /test.sh
#!/bin/bash

# 定义一个名为sigterm_handler的函数
sigterm_handler() {
  echo "捕获到SIGTERM信号,正在退出..."
  exit 0
}

if [ "$#" -ne 1 ]; then
  echo "用法: $0 [0|1]"
  echo "0: 不注册SIGTERM handler"
  echo "1: 注册SIGTERM handler"
  exit 1
fi

if [ "$1" -eq 1 ]; then
  # 使用trap命令注册sigterm_handler函数,当接收到SIGTERM信号时执行
  trap 'sigterm_handler' SIGTERM
  echo "已注册SIGTERM handler"
else
  echo "未注册SIGTERM handler"
fi


echo "脚本正在运行,按Ctrl C发送SIGINT信号,使用'kill -15 <PID>'发送SIGTERM信号"
while true; do
  sleep 1
done


先看下不注册SIGTERM handler的运行脚本的情况:
# docker ps | grep test-no-handler
e23e875616b5     test-sigterm:latest"/test.sh 0"     6 minutes ago       Up 6 minutes  test-no-handler

"/test.sh 0" 是容器test-no-handler的1号进程,对应到节点上的进程pid为2754618
[root@VM-0-20-centos ~]# docker inspect e23e875616b5 | grep -i pid
            "Pid": 2754618,
            "PidMode": "",
            "PidsLimit": null,

[root@VM-0-20-centos ~]# ps -elf | grep 2754618
4 S root     2754618 2754595  0  80   0 -  2237 do_wai 23:32 pts/0    00:00:00 /bin/bash /test.sh 0



在节点上对容器1号进程(对应节点pid=2754618)发送SIGTERM信号,容器1号进程并不会退出
[root@VM-0-20-centos ~]# kill -15 2754618
[root@VM-0-20-centos ~]# ps -elf | grep 2754618
4 S root     2754618 2754595  0  80   0 -  2237 do_wai 23:32 pts/0    00:00:00 /bin/bash /test.sh 0


[root@VM-0-20-centos ~]# docker ps | grep test-no-handler
e23e875616b5        test-sigterm:latest   "/test.sh 0"             10 minutes ago      Up 10 minutes                           test-no-handler
[root@VM-0-20-centos ~]#

接下来分析下这里的机制:

当我们通过kill给进程发送信号时,内核会通过如下调用路径来决定这个信号是否丢弃:

__send_signal->prepare_signal->sig_ignored->sig_task_ignored

首先我们先来看下sig_task_ignored这个函数什么情况会返回1

代码语言:javascript复制
static int sig_task_ignored(struct task_struct *t, int sig, bool force)
{
        void __user *handler;

        handler = sig_handler(t, sig);

        if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
            handler == SIG_DFL && !(force && sig_kernel_only(sig)))
                return 1;

        return sig_handler_ignored(handler, sig);
}


函数sig_task_ignored如果要返回1,则这里的条件要成立:
if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
            handler == SIG_DFL && !(force && sig_kernel_only(sig)))
                return 1;

 第一个条件“t->signal->flags & SIGNAL_UNKILLABLE”   的flags SIGNAL_UNKILLABLE 在创建进程时当进程是
 namespace的1号进程时会设置
 kernel/fork.c:
 struct task_struct *copy_process(
                                        unsigned long clone_flags,
                                        unsigned long stack_start,
                                        unsigned long stack_size,
                                        int __user *child_tidptr,
                                        struct pid *pid,
                                        int trace,
                                        unsigned long tls,
                                        int node)
 {
      .....
      if (is_child_reaper(pid)) {
                                ns_of_pid(pid)->child_reaper = p;
                                p->signal->flags |= SIGNAL_UNKILLABLE;
                        }
      .... 
 }                    
 /*
 * is_child_reaper returns true if the pid is the init process
 * of the current namespace. As this one could be checked before
 * pid_ns->child_reaper is assigned in copy_process, we check
 * with the pid number.
 */
static inline bool is_child_reaper(struct pid *pid)
{
        return pid->numbers[pid->level].nr == 1;
}

可以通过live crash来确认p->signal->flags是否为SIGNAL_UNKILLABLE:
#define SIGNAL_UNKILLABLE       0x00000040 /* for init: ignore fatal signals */

crash> bt 2754618
PID: 2754618  TASK: ffff88815f908000  CPU: 0   COMMAND: "test.sh"
 
crash>
crash> task_struct.signal ffff88815f908000
  signal = 0xffff888169e50000
crash> struct signal_struct.flags 0xffff888169e50000 -x
  flags = 0x40
crash> eval -b 0x40
hexadecimal: 40
    decimal: 64
      octal: 100
     binary: 0000000000000000000000000000000000000000000000000000000001000000
   bits set: 6
crash>



第二个条件 “handler == SIG_DFL”,第二个条件判断信号的handler是否是SIG_DFL。
对于每个信号,用户进程如果不注册一个自己的handler,就会有一个系统缺省的handler,
这个缺省的handler就叫作SIG_DFL: 

同样可以通过live crash来确认进程的SIGTERM信号handler为0,sig为15,因此对应action[sig - 1]为
t->sighand->action[14].sa.sa_handler
static void __user *sig_handler(struct task_struct *t, int sig)
{
        return t->sighand->action[sig - 1].sa.sa_handler;
}
 
crash> bt 2754618
PID: 2754618  TASK: ffff88815f908000  CPU: 2   COMMAND: "test.sh"

crash> task_struct.sighand ffff88815f908000 -x
  sighand = 0xffff888216fd0000

crash> struct sighand_struct.action[14] 0xffff888216fd0000
  action[14] =   {
    sa = {
      sa_handler = 0x0,
      sa_flags = 0,
      sa_restorer = 0x0,
      sa_mask = {
        sig = {0}
      }
    }
  },
crash>

通过/proc/2754618/status也可以确定bit14(对应15:SIGTERM)为0未注册SIGTERM信号handler:
# cat /proc/2754618/status | grep SigCgt
SigCgt: 0000000000010002 //16进制



第三个条件!(force && sig_kernel_only(sig) 这里sig_kernel_only(sig)对于SIGTERM信号返回值为0

#define siginmask(sig, mask) 
        ((sig) < SIGRTMIN && (rt_sigmask(sig) & (mask)))
#define SIG_KERNEL_ONLY_MASK (
        rt_sigmask(SIGKILL)   |  rt_sigmask(SIGSTOP))
#define sig_kernel_only(sig)            siginmask(sig, SIG_KERNEL_ONLY_MASK)  

因此容器1号进程未注册handler的情况下这三个条件都成立,sig_task_ignored返回值1。

代码语言:javascript复制

返回sig_task_ignored的上一级函数sig_ignored,通过live crash可以看到进程的t->ptrace为0,所以最终
返回的是sig_task_ignored的返回值:

crash> task_struct.ptrace ffff88815f908000
  ptrace = 0
crash>

static int sig_ignored(struct task_struct *t, int sig, bool force)
{
        /*
         * Blocked signals are never ignored, since the
         * signal handler may change by the time it is
         * unblocked.
         */
        if (sigismember(&t->blocked, sig) || sigismember(&t->real_blocked, sig))
                return 0;

        /*
         * Tracers may want to know about even ignored signal unless it
         * is SIGKILL which can't be reported anyway but can be ignored
         * by SIGNAL_UNKILLABLE task.
         */    
        if (t->ptrace && sig != SIGKILL)
                return 0;

        return sig_task_ignored(t, sig, force);
}


这里sig_ignored返回1,所以prepare_signal返回值为0
static bool prepare_signal(int sig, struct task_struct *p, bool force)
{
    ....
    return !sig_ignored(p, sig, force);
    ....
}

因此当!prepare_signal条件为1时__send_signal直接goto到ret:位置返回跳过了给目标进程发送信号的逻辑:
static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
                        int group, int from_ancestor_ns)
{
    ....
    result = TRACE_SIGNAL_IGNORED; //TRACE_SIGNAL_IGNORED值为1
        if (!prepare_signal(sig, t,
                        from_ancestor_ns || (info == SEND_SIG_PRIV) || (info == SEND_SIG_FORCED)))
                goto ret;

    ....
    
    ....
    out_set:
        signalfd_notify(t, sig);
        sigaddset(&pending->signal, sig);
        complete_signal(sig, t, group);
    ret:
        trace_signal_generate(sig, info, t, group, result);
        return ret;
}

从__send_signal函数可以看到函数退出前会调用trace_signal_generate调用trace点,因此可以通过perf trace来跟踪:

代码语言:javascript复制
起一个终端,该终端负责执行kill -15给容器1号进程发送SIGTERM信号,先获取下该终端的进程pid
[root@VM-0-20-centos ~]# echo $$
3492032

再另外一个终端执行perf trace跟中pid 3492032发送的信号:
#perf trace -e signal:signal_generate --pid=3492032

回到进程ID为3492032的bash终端,执行kill -15给容器1号进程发送SIGTERM信号
[root@VM-0-20-centos ~]# echo $$
3492032
[root@VM-0-20-centos ~]#
[root@VM-0-20-centos ~]# ps -elf | grep test.sh | grep -v grep
4 S root     2754618 2754595  0  80   0 -  2237 do_wai Aug10 pts/0    00:00:11 /bin/bash /test.sh 0
[root@VM-0-20-centos ~]#
[root@VM-0-20-centos ~]# kill -15 2754618
[root@VM-0-20-centos ~]#

这时在执行perf trace的终端可以看到如下心信息,因为
trace_signal_generate(sig, info, t, group, result)的第四个参数result值为res=1,1对应的是
TRACE_SIGNAL_IGNORED就是忽略信号。也就是发送给容器1号进程(对应节点pid=2754618)被内核drop掉了,
容器1号进程并不会收到这个信号:
[root@VM-0-20-centos ~]# perf trace -e signal:signal_generate --pid=3492032
 84645.140 signal:signal_generate:sig=15 errno=0 code=0 comm=test.sh pid=2754618 grp=1 res=1

有人可能会疑惑既然内核drop了这个信号,为啥用strace跟踪容器的1号进程时还能捕获到SIGTERM信号发送给容器1号进程?

--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---

这里的原因是因为当对一个进程做了strace后,会把进程task_struct.ptrace设置为1,我们再看下前面提到的sig_ignored

函数会有if(t->ptrace && sig !=SIGKILL)的判断逻辑:

代码语言:javascript复制


static int sig_ignored(struct task_struct *t, int sig, bool force)
{
      ....
        /*
         * Tracers may want to know about even ignored signal unless it
         * is SIGKILL which can't be reported anyway but can be ignored
         * by SIGNAL_UNKILLABLE task.
         */    
        if (t->ptrace && sig != SIGKILL)
                return 0;
   ....
     
}

当容器1号进程没有被strace时,ptrace值为0,当执行了strace后被strace的进程task_struct.ptrace会被设置为非0:
crash> task_struct.ptrace ffff88815f908000
  ptrace = 0

 对容器1号进程进行strace后再看ptrace值被设置为非0 
crash> task_struct.ptrace ffff88815f908000 -x
  ptrace = 0x10289
crash>

当对容器1号进程做了strace后,执行kill -15 $pid时通过perf trace可以
看到res=0也就是TRACE_SIGNAL_DELIVERED,信号确实发送给了容器1号进程(对应节点pid=2754618),
只不过当进程task_struct.ptrace设置了ptrace后,信号响应处理函数do_signal处理逻辑针对SIGTERM不会终止进程。
# perf trace -e signal:signal_generate --pid=3492032
     0.000 signal:signal_generate:sig=15 errno=0 code=0 comm=test.sh pid=2754618 grp=1 res=0

接下来再来看下当脚本注册了信号SIGTERM的handler后是什么效果,还是参考上面的方法模拟:

代码语言:javascript复制
启动一个容器并且容器1号进程执行/bin/bash /test.sh 1, test.sh脚本会注册SIGTERM信号handler:
# ps -elf | grep "test.sh 1" | grep -v grep
4 S root     3581760 3581739  1  80   0 -  2237 do_wai 11:28 pts/0    00:00:00 /bin/bash /test.sh 1


crash> bt 3581760
PID: 3581760  TASK: ffff888181e68000  CPU: 1   COMMAND: "test.sh"
 
crash>
crash>
crash> task_struct.sighand ffff888181e68000
  sighand = 0xffff8881898c8000
crash> struct sighand_struct.action[14] 0xffff8881898c8000
  action[14] =   {
    sa = {
      sa_handler = 0x560076d84990,
      sa_flags = 67108864,
      sa_restorer = 0x7fbbb81acf00,
      sa_mask = {
        sig = {0}
      }
    }
  },
crash>
通过live crash可以看到当注册了handler后,sa_handler不为0,也就是sig_task_ignored函数的
handler == SIG_DF这个判断条件是不成立的,因此sig_task_ignored返回0。
static int sig_task_ignored(struct task_struct *t, int sig, bool force)
{
        void __user *handler;

        handler = sig_handler(t, sig);

        if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
            handler == SIG_DFL && !(force && sig_kernel_only(sig)))
                return 1;

        return sig_handler_ignored(handler, sig);
}

static int sig_handler_ignored(void __user *handler, int sig)
{
        /* Is it explicitly or implicitly ignored? */
        return handler == SIG_IGN ||
                (handler == SIG_DFL && sig_kernel_ignore(sig));
}

进程的status的SigCgt bit14也被设置为1:
# cat /proc/3581760/status | grep SigCgt
SigCgt: 0000000000014002

hexadecimal: 14002
     binary: 0000000000000000000000000000000000000000000000010100000000000010
   bits set: 16 14 1

同样在pid为3492032的控制终端发起kill -15 给注册了SIGTERM 信号handler的容器1号进程
# echo $$
3492032
# kill -15 3581760

perf trace跟踪pid 3492032发送的信号可以看到给容器1号进程(对应节点pid=3581760)发送的SIGTERM信号被内核提交给了
容器1号进程(这里对应节点pid=3581760),res=0代表TRACE_SIGNAL_DELIVERED:
# perf trace -e signal:signal_generate --pid=3492032
     0.000 signal:signal_generate:sig=15 errno=0 code=0 comm=test.sh pid=3581760 grp=1 res=0
 
 docker log也可以看到容器1号进程响应了SIGTERM并执行了注册的handler:        
[root@VM-0-20-centos ~]# docker logs 6a7abc307a6b
已注册SIGTERM handler
脚本正在运行,按Ctrl C发送SIGINT信号,使用'kill -15 <PID>'发送SIGTERM信号
捕获到SIGTERM信号,正在退出...

0 人点赞