正确处理 SIGTERM 信号
业务代码处理 SIGTERM 信号
要实现优雅终止,首先业务代码得支持下优雅终止的逻辑,在业务代码里面处理下 SIGTERM
信号,一般主要逻辑就是"排水",即等待存量的任务或连接完全结束,再退出进程。
本文给出各种语言的代码示例。
- go
- shell
- Python
- NodeJS
- Java
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
// registers the channel
signal.Notify(sigs, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Println("Caught SIGTERM, shutting down")
// Finish any outstanding requests, then...
done <- true
}()
fmt.Println("Starting application")
// Main logic goes here
<-done
fmt.Println("exiting")
}
#!/bin/sh
## Redirecting Filehanders
ln -sf /proc/$$/fd/1 /log/stdout.log
ln -sf /proc/$$/fd/2 /log/stderr.log
## Pre execution handler
pre_execution_handler() {
## Pre Execution
# TODO: put your pre execution steps here
: # delete this nop
}
## Post execution handler
post_execution_handler() {
## Post Execution
# TODO: put your post execution steps here
: # delete this nop
}
## Sigterm Handler
sigterm_handler() {
if [ $pid -ne 0 ]; then
# the above if statement is important because it ensures
# that the application has already started. without it you
# could attempt cleanup steps if the application failed to
# start, causing errors.
kill -15 "$pid"
wait "$pid"
post_execution_handler
fi
exit 143 # 128 + 15 -- SIGTERM
}
## Setup signal trap
# on callback execute the specified handler
trap 'sigterm_handler' SIGTERM
## Initialization
pre_execution_handler
## Start Process
# run process in background and record PID
"$@" >/log/stdout.log 2>/log/stderr.log &
pid="$!"
# Application can log to stdout/stderr, /log/stdout.log or /log/stderr.log
## Wait forever until app dies
wait "$pid"
return_code="$?"
## Cleanup
post_execution_handler
# echo the return code of the application
exit $return_code
import signal, time, os
def shutdown(signum, frame):
print('Caught SIGTERM, shutting down')
# Finish any outstanding requests, then...
exit(0)
if __name__ == '__main__':
# Register handler
signal.signal(signal.SIGTERM, shutdown)
# Main logic goes here
process.on('SIGTERM', () => {
console.log('The service is about to shut down!');
// Finish any outstanding requests, then...
process.exit(0);
});
import sun.misc.Signal;
import sun.misc.SignalHandler;
public class ExampleSignalHandler {
public static void main(String... args) throws InterruptedException {
final long start = System.nanoTime();
Signal.handle(new Signal("TERM"), new SignalHandler() {
public void handle(Signal sig) {
System.out.format("\nProgram execution took %f seconds\n", (System.nanoTime() - start) / 1e9f);
System.exit(0);
}
});
int counter = 0;
while(true) {
System.out.println(counter++);
Thread.sleep(500);
}
}
}
为什么程序收不到 SIGTERM 信号?
我们的业务代码通常会捕捉 SIGTERM
信号,然后执行停止逻辑以实现优雅终止。在 Kubernetes 环境中,业务发版时经常会对 workload 进行滚动更新,当旧版本 Pod 被删除时,K8S 会对 Pod 中各个容器中的主进程发送 SIGTERM
信号,当达到超时时间进程还未完全停止的话,K8S 就会发送 SIGKILL
信号将其强制杀死。
业务在 Kubernetes 环境中实际运行时,有时候可能会发现在滚动更新时,我们业务的优雅终止逻辑并没有被执行,现象是在等了较长时间后,业务进程直接被 SIGKILL
强制杀死了。
什么原因?
通常都是因为容器启动入口使用了 shell,比如使用了类似 /bin/sh -c my-app
这样的启动入口。 或者使用 /entrypoint.sh
这样的脚本文件作为入口,在脚本中再启动业务进程:
这就可能就会导致容器内的业务进程收不到 SIGTERM
信号,原因是:
-
容器主进程是 shell,业务进程是在 shell 中启动的,成为了 shell 进程的子进程。
-
shell 进程默认不会处理
SIGTERM
信号,自己不会退出,也不会将信号传递给子进程,导致业务进程不会触发停止逻辑。 -
当等到 K8S 优雅停止超时时间 (
terminationGracePeriodSeconds
,默认 30s),发送SIGKILL
强制杀死 shell 及其子进程。
如何解决?
- 如果可以的话,尽量不使用 shell 启动业务进程。
- 如果一定要通过 shell 启动,比如在启动前需要用 shell 进程一些判断和处理,或者需要启动多个进程,那么就需要在 shell 中传递下 SIGTERM 信号了,解决方案请参考 在 SHELL 中传递信号 。
在 SHELL 中传递信号
在 Kubernetes 中,Pod 停止时 kubelet 会先给容器中的主进程发 SIGTERM
信号来通知进程进行 shutdown 以实现优雅停止,如果超时进程还未完全停止则会使用 SIGKILL
来强行终止。
但有时我们会遇到一种情况: 业务逻辑处理了 SIGTERM
信号,但 Pod 停止时好像没收到信号导致优雅停止逻辑不生效。
通常是因为我们的业务进程是在脚本中启动的,容器的启动入口使用了脚本,所以容器中的主进程并不是我们所希望的业务进程而是 shell 进程,导致业务进程收不到 SIGTERM
信号,更详细的原因在上一节我们已经介绍了,下面将介绍几种解决方案。
使用 exec 启动
在 shell 中启动二进制的命令前加一个 exec 即可让该二进制启动的进程 代替当前 shell 进程,即让新启动的进程成为主进程:
#! /bin/bash
...
exec /bin/yourapp # 脚本中执行二进制
然后业务进程就可以正常接收所有信号了,实现优雅退出也不在话下。
多进程场景: 使用 trap 传递信号
通常我们一个容器只会有一个进程,也是 Kubernetes 的推荐做法。但有些时候我们不得不启动多个进程,比如从传统部署迁移到 Kubernetes 的过渡期间,使用了富容器,即单个容器中需要启动多个业务进程,这时也只能通过 shell 启动,但无法使用上面的 exec
方式来传递信号,因为 exec
只能让一个进程替代当前 shell 成为主进程。
这个时候我们可以在 shell 中使用 trap
来捕获信号,当收到信号后触发回调函数来将信号通过 kill
传递给业务进程,脚本示例:
#! /bin/bash
/bin/app1 & pid1="$!" # 启动第一个业务进程并记录 pid
echo "app1 started with pid $pid1"
/bin/app2 & pid2="$!" # 启动第二个业务进程并记录 pid
echo "app2 started with pid $pid2"
handle_sigterm() {
echo "[INFO] Received SIGTERM"
kill -SIGTERM $pid1 $pid2 # 传递 SIGTERM 给业务进程
wait $pid1 $pid2 # 等待所有业务进程完全终止
}
trap handle_sigterm SIGTERM # 捕获 SIGTERM 信号并回调 handle_sigterm 函数
wait # 等待回调执行完,主进程再退出