一般来说,我们使用的Linux系统或者Windows又或者macOS在关机的时候都不会直接拔电源,而是点关机按钮或者使用命令行 输入诸如shutdown now此类的命令优雅的关机。那是因为通常不应该直接强行退出应用程序,而是在退出之前通过信号机制给应用进程发送指定的信号. 应用收到信号后进行状态保存,资源回收等操作最后才安全地退出1_NII9Htj87LjmNIa1PJzgCA.png

应用场景

众所周知,docker容器利用linux namespace原理将应用隔离在独立的环境,但它的本质还是一个进程,而且是跑在linux系统之上的。 一般来说,我们使用的Linux系统或者Windows又或者macOS在关机的时候都不会直接拔电源,而是点关机按钮或者使用命令行 输入诸如shutdown now此类的命令优雅的关机。那是因为通常不应该直接强行退出应用程序,而是在退出之前通过信号机制给应用进程发送指定的信号. 应用收到信号后进行状态保存,资源回收等操作最后才安全地退出。

应用退出之前需要信号机制的几个场景:

  1. 持久化,程序需要将RAM中内容保存到磁盘上,然后在下次启动加载回内存,通常来说磁盘的速度肯定不如RAM的,因此问题经常出现在读取或者保存到磁盘的慢读写。
  2. 日志记录,使用文件来保存电源状态,比如1表示电源掉电,0表示电源开启,
  3. 应用的需要区分不同信号,内核发送信号给进程,进程注册相应的处理机制,对特定信号做自行处理

普通的进程如此,同样,对应容器内的进程也是一样,只是之间多了容器运行时的管理。 操作系统并不之间控制容器进程,而是通过Kubernetes和docker的行为进行生命周期的控制。

什么是信号

> 对计算机原理有点了解的都知道,程序中断包括:硬件中断和软件中断。 > 其中信号则属于软件中断的一种,信号是进程之间或者OS和硬件之间进行状态通信的一种沟通方式

顾名思义,中断对进程而言,就是收到一个中断信号,打断正在执行的内容,即不管正在做什么内容 ,先把当前的工作暂停下来,然后根据收到的信号执行特定的动作(又或者什么都不做),反正就是一种通知方式. interupt.png

能引发上图中暂停主进程操作的信号,在linux内核定义如下(x86):

Signal     Value     Action   Comment
----------------------------------------------------------------------
SIGHUP        1       Term    Hangup detected on controlling terminal
                              or death of controlling process
SIGINT        2       Term    Interrupt from keyboard
SIGQUIT       3       Core    Quit from keyboard
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 tty
SIGTTIN   21,21,26    Stop    tty input for background process
SIGTTOU   22,22,27    Stop    tty output for background process

上表列出了常见的信号值和对应的行为。

举个例子,我们使用kill命令杀死一个进程的操作

kill -9 $pid

就是向pid进程发送信号9,即KILL信号给进程。

<table> <thead> <tr> <th>信号</th> <th>描述</th> </tr> </thead> <tbody> <tr> <td>SIGHUP</td> <td>当用户退出终端时,由该终端开启的所有进程都退接收到这个信号,默认动作为终止进程。</td> </tr> <tr> <td>SIGINT</td> <td>程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl+C)时发出,用于通知前台进程组终止进程。</td> </tr> <tr> <td>SIGQUIT</td> <td>和SIGINT类似, 但由QUIT字符(通常是Ctrl+)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。</td> </tr> <tr> <td>SIGKILL</td> <td>用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。</td> </tr> <tr> <td>SIGTERM</td> <td>程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出。</td> </tr> <tr> <td>SIGSTOP</td> <td>停止(stopped)进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略.</td> </tr> </tbody> </table>

命令行下发送命令

登录到中断上,我们很多操作都可以触发各种信号。 按下 ctrl+c ,此时会发送 SIGINT 给正在执行中的的进程 而按下 ctrl+z ,此时会发送 SIGTSTP 信号 而fg或者bg命令则可以发送 SIGCONT signal

docker传递信号给容器

  1. 当执行docker stop命令的时候,docker发送SIGTERM到容器内PID=1的主进程, 然后等待10秒后如果进程还没有正常终止的话则继续发送SIGKILL到内核直接终止该进程。

  2. 当执行docker kill命令的时候,docker就不会给容器进程优雅退出的机会,而是直接把进程杀死

kubernetes如何处理容器进程的信号

当执行kubectl delete pod mypod 或直接使用API DELETE https://apiserver.cluster.local:6443/api/v1/namespaces/default/pods/mypod, 类似docker,它会先发送一个SIGTERM信号给进程,然后等待若干秒⌛️,再发SIGKILL, 这里所谓的若干秒,就是k8s里面称之为grace period,具体参考文档podSpec, 默认优雅期为30秒。

如果你的进程不处理SIGTERM,那么它最终得到的是SIGKILL,进程和相应的资源会立即从etcd和API移除,不再等待node上终止再回收♻️。

换而言之,如果你需要应用优雅结束,那么你需要实现SIGTERM相应的handler

注意⚠️:

  1. 如果Pod内有多个容器,他们都会收到SIGTERM信号,因此你可能需要特别注意这点,因为对应有些程序而言,信号不处理好会掉坑里

  2. 还有一个非常常见的错误,就是CMD指令格式不正确。 举个例子🌰,你的应用不是容器里的主进程,而是作为shell启动的子进程,大概是这样的格式/bin/sh -c myapplication。 按照这种格式启动的容器虽然也能运行起来,但是信号处理并不如期。因为默认的shell在收到kubectl delete请求后并不会把信号转发给子进程。 因此你的子进程myapplication并没有收到SIGTERM,而不能优雅退出,只会最后被SIGKILL干掉

信号重写

可以使用dumb-init对信号进行重写。例如如果要nginx优雅结束,其实应是要先发送SIGQUIT信号, nginx收到此信号会安全结束,然而,并没docker命令可以直接发SIGQUIT给容器进程。但是我们前面说了,Kubernetes进行delete之前会发送SIGTERM, 而且时间可以自定义,这就足够了,通过信号重写,将SIGTERM重写为SIGQUIT就好办了。

示例如下:

## for full source, check https://github.com/Yelp/casper/blob/master/Dockerfile.opensource
FROM ubuntu:xenial

# Manually install dumb-init as it's not in the public APT repo
RUN wget https://github.com/Yelp/dumb-init/releases/download/v1.2.1/dumb-init_1.2.1_amd64.deb
RUN dpkg -i dumb-init_*.deb

## Your application requirements

# Rewrite SIGTERM(15) to SIGQUIT(3) to let Nginx shut down gracefully
CMD ["dumb-init", "--rewrite", "15:3", "/code/start.sh"]

golang处理信号

golang捕获信号十分简单:

写个demo如下main.go

package main

import (
	"fmt"
	"os"
	"os/exec"
	"os/signal"
	"syscall"
)

func main() {
	cmd := exec.Command("ping")

	cmd.Args = []string{"ping", "baidu.com"}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	signalChan := make(chan os.Signal, 1)
	signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) //handle signal during grace period
	go func() {
		sig := &lt;-signalChan
		fmt.Printf("catch kubernetes signal:%v, stop cmd:%v \n", sig, cmd.Args)
		cmd.Process.Signal(os.Interrupt)
	}()

	if err := cmd.Run(); err != nil {
		fmt.Printf("Error running the /bin/sh command - %s\n", err)
		os.Exit(1)
	}
}

编译执行并测试:

go build -o sig main.go
./sig

catch.png

可以看到已经捕获到kill 发送的信号

这里只是演示ping,如果是在实战中,这可能是个能处理Interrupt信号的进程

我所遇到的一个真实场景是这样的:

  1. 假设上述代码被打包📦并部署成Pod,并在进程内启动了restic进行restore操作 //restic是个备份还原工具
  2. 子进程restic在restore过程会进行加锁
  3. 如果这时候执行kubectl delete pod xxx, 如果不把SIGINT转发给restic子进程,那么restic 加的锁就没有释放Pod就被干掉了,后续再使用restic就出现死锁问题,就不能正常使用
  4. 因此需要kubetctl delete完成之前的时候把SIGINT发给restic子进程,让resic释放锁后安全退出

暂且使用ping代替restic,在kubernetes中部署起来模拟下:

注意同目录下写入dockerfile

FROM debian:stretch
LABEL maintainers="Hoyho"
LABEL description="here2say/SIGNAL"

WORKDIR /app

COPY sig /app/
ENTRYPOINT ["/app/sig"]
go build -o sig main.go
docker build -t signal-demo .
kubectl run sig --image=signal-demo:latest --image-pull-policy=IfNotPresent

下面是两个终端的输出: 一个显示logs

/tmp/signal # kubectl get po | grep sig                      root@vm43
sig-6c666678fb-9h7fg              1/1     Running   0          45s
-----------------------------------------------------------------------
/tmp/signal # kubectl logs sig-6c666678fb-9h7fg  -f          root@vm43
PING baidu.com (39.156.69.79) 56(84) bytes of data.
64 bytes from 39.156.69.79 (39.156.69.79): icmp_seq=1 ttl=50 time=2.76 ms
64 bytes from 39.156.69.79 (39.156.69.79): icmp_seq=85 ttl=50 time=3.04 ms
64 bytes from 39.156.69.79 (39.156.69.79): icmp_seq=86 ttl=50 time=7.25 ms
catch kubernetes signal:terminated, stop cmd:[ping baidu.com]

--- baidu.com ping statistics ---
86 packets transmitted, 86 received, 0% packet loss, time 85174ms
--------------------------------------------------------------
/tmp/signal #

一个发送kubect delete 命令

/tmp/signal # kubectl delete deploy sig                      root@vm43
deployment.apps "sig" deleted

最终输出日志catch kubernetes signal:terminated 显示结果是在执行kubectl delete的时候是收到了SIGTERM, 因此代码里面收到此信号后可以根据需求给子进程发送结束信号即可优雅地退出

ref

  • https://devops.college/the-journey-from-monolith-to-docker-to-kubernetes-part-1-f5dbd730f620
  • https://juejin.im/post/5d19914df265da1bd2610308