目 录CONTENT

文章目录

信号与优雅停机

FatFish1
2025-03-25 / 0 评论 / 0 点赞 / 68 阅读 / 0 字 / 正在检测是否收录...

docker优雅停机原理

Linux的信号

docker环境优雅停机是基于Linux信号实现的,常见的Linux信号包括:

  • SIGHUP(1):当用户终端连接结束时,系统会像所有运行中的进程发出这个信号;通常在热加载配置文件时候也会使用该信号。wget命令就注册了SIGHUP(1)信号,这样就算你退出了Linux登录,wget也能继续下载文件。同样的,如Docker/Nginx/LVS等服务也会注册SIGHUP(1)信号,实现服务的热加载配置文件功能。

  • SIGINT(2):程序终止(interrupt)信号,在用户键入INTR字符(通常是Ctrl+C)时发出,用于通知前台进程组终止进程。

  • SIGQUIT(3):和SIGINT类似,但由QUIT字符(通常是Ctrl+反斜杠)来控制。Nginx就是通过注册这个信号来实现优雅停止服务的。

  • SIGKILL(9):立刻结束程序。该信号不能被阻塞、处理和忽略,不能在程序中被获取到

  • SIGTERM(15):程序结束(Terminate)信号,又叫请求退出信号,与SIGKILL不同的是该信号可以被阻塞和处理,我们可以通过在程序中注册该信号来实现服务的优雅停止。使用kill命令缺省会发出这个信号

  • SIGCHLD(17):子进程结束时,一般会向父进程发送这个信号。Nginx是个多进程程序,master进程和worker进程通信就使用的这个信号。

通过Linux的kill命令就看可以向JVM发送信号,使用kill -l可以查看所有信号列表,然后执行kill -9 <PID> ,就可以模拟SIGKILL信号发送的过程了

docker stop与退出码

docker命令参考如下链接:

http://www.chymfatfish.cn/archives/dockercommand#docker%E5%91%BD%E4%BB%A4

docker stop命令本质上就是利用了Linux信号实现容器停止,当我们用docker stop命令来停掉容器的时候,docker默认会允许容器中的应用程序有10秒的时间用以终止运行。我们可以通过在执行docker stop --timedocker stop -t参数来自定义一个stop时间长度

在docker stop命令执行的时候,会先向容器中PID为1的进程(main process)发送系统信号SIGTERM,然后等待容器中的应用程序终止执行,如果等待时间达到设定的超时时间,如默认的10秒,会继续发送SIGKILL的系统信号强行kill掉进程。在容器中的应用程序,可以选择忽略和不处理SIGTERM信号,不过一旦达到超时时间,程序就会被系统强行kill掉

因为根据信号做了不同的容器响应,在退出后容器上的退出码也会有不同的展示。docker容器的退出码值=128+信号值(Exit Code 1除外),举例如下:

  • Exit Code 1 :程序错误,pod直接就没起来,例如Dockerfile中引用不存在的文件,或entrypoint.sh中引用了错误的包

  • Exit Code 137:128+9,即响应SIGKILL信号或kill -9的结果,手动docker kill会得到这个退出码,或者pod中limit设置过小,导致pod产生OOMKilled,也会间接产生Exit Code 137退出码

  • Exit Code 139:128+11,即SIGSEGV信号或kill -11,对应无效的内存引用,一般是代码问题或docker基础镜像问题

  • Exit Code 143:128+15,即SIGTERM或kill -15,意味着pod是优雅停机的,比如执行docker stop。但是如果服务无法处理SIGTERM信号,也会让docker stop等待10s无响应后,产生137退出码

  • 其他信号:126是权限问题或容器启动时执行设置的bash命令不可执行,比如docker file中的ENTRYPOINT命令或CMD命令;127是shell脚本中的错字或无法识别的语句;255是1在某些场景下转换过来的,和1是一个意思

其他响应信号的docker命令

其他的docker命令,例如docker kill ,则是直接发送SIGKILL信号,不给任何优雅停机的机会;docker rm 用于删除已经停机的容器,如果不停机,则无法删除,但一旦使用docker rm -f ,则会先发送SIGKILL信号,再删除容器

docker daemon命令比较特殊,它接收SIGHUP信号,接收后会重新reload daemon.json配置文件

例如执行以下命令:

root@vm10-1-1-28:~# kill -SIGHUP $(pidof dockerd)
# 或者
root@vm10-1-1-28:~# systemctl reload docker

查看docker daemon的日志可以看到,docker daemon接收这个信号并重新reload daemon.json配置文件

root@vm10-1-1-28:~# journalctl -u docker.service -f
-- Logs begin at Sun 2018-01-07 09:17:01 CST. --
Jan 18 16:20:11 vm10-1-1-28.ksc.com dockerd[26668]: time="2018-01-18T16:20:11.262904839+08:00" level=info msg="Got signal to reload configuration, reloading from: /etc/docker/daemon.json"
Jan 18 16:21:41 vm10-1-1-28.ksc.com systemd[1]: Reloading Docker Application Container Engine.

所以修改完/etc/docker/daemon.json文件后,可以直接给Docker发送一个SIGHUP信号实现配置文件的reload,而不需要重启docker daemon

而systemctl reload docker 命令通常不会导致机器上的容器重启。这个命令的作用是让 Docker 守护进程重新加载其配置文件,而不会中断正在运行的容器。它和 systemctl restart docker 是不同的,后者会停止并重新启动 Docker 服务,从而导致所有容器重启。

JAVA程序对SIGTERM信号的响应

想要真正优雅退出,需要java服务对SIGTERM信号作出响应

Java 中的 Shutdown Hook 提供了比较好的方案。我们可以通过 Java.Runtime.addShutdownHook(Thread hook) 方法向 JVM 注册关闭钩子,在 JVM 退出之前会自动调用执行钩子方法,做一些结尾操作,从而让进程平滑优雅的退出,保证了业务的完整性。

Spring提供了默认的ShutdownHook实现,可以参考AbstractApplicationContext#registerShutdownHook

public void registerShutdownHook() {
    if (this.shutdownHook == null) {
       // No shutdown hook registered yet.
       this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
          @Override
          public void run() {
             synchronized (startupShutdownMonitor) {
                doClose();
             }
          }
       };
       Runtime.getRuntime().addShutdownHook(this.shutdownHook);
    }
}

该方法设置成员变量this.shutdownHook ,然后在执行该方法的时候调用Runtime.getRuntime().addShutdownHook(this.shutdownHook); 添加进去

那么registerShutdownHook什么时候调用呢?

这个就看使用什么框架了,例如自行搭建基于SpringFramework的e2e框架时,可以使用SpringTestListener的实现:

@Override
public void beforeTestClass(TestContext testContext) throws Exception {
        ApplicationContext applicationContext = testContext.getApplicationContext();
        SpringTestListener.applicationContext = applicationContext;
        ……
}

参考如下链接:

http://www.chymfatfish.cn/archives/e2econstructor#%E5%AE%9E%E7%8E%B0spring%E7%8E%AF%E5%A2%83%E5%90%AF%E5%8A%A8%E5%90%8E%E7%BD%AE%E5%A4%84%E7%90%86%E5%99%A8

再比如华为贡献的CSE框架,则是监听了上下文启动的event,直接就注册进去的:

// org.apache.servicecomb.core.CseApplicationListener#onApplicationEvent
public void onApplicationEvent(ApplicationEvent event) {
  if (initEventClass.isInstance(event)) {
    if (applicationContext instanceof AbstractApplicationContext) {
      ((AbstractApplicationContext) applicationContext).registerShutdownHook();
    }
   ……
  }
}

然后就是看下spring在ShutdownHook里面都做什么,可以看到AbstractApplicationContext#doClose

protected void doClose() {
    // Check whether an actual close attempt is necessary...
    if (this.active.get() && this.closed.compareAndSet(false, true)) {
       ……
       // 发布关闭事件,调用方可以自行监听
       try {
          // Publish shutdown event.
          publishEvent(new ContextClosedEvent(this));
       }
       ……
       // Stop all Lifecycle beans, to avoid delays during individual destruction.
       // 这里停止声明周期bean
       if (this.lifecycleProcessor != null) {
          try {
             this.lifecycleProcessor.onClose();
          }
          catch (Throwable ex) {
             logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
          }
       }
       // Destroy all cached singletons in the context's BeanFactory.
       // 这里关闭所有单例bean
       destroyBeans();
       // Close the state of this context itself.
       // 然后关闭bean工厂
       closeBeanFactory();
       // 抽象方法,给子类自行实现
       onClose();
       // Reset common introspection caches to avoid class reference leaks.
       resetCommonCaches();
       // Reset local application listeners to pre-refresh state.
       if (this.earlyApplicationListeners != null) {
          this.applicationListeners.clear();
          this.applicationListeners.addAll(this.earlyApplicationListeners);
       }
       // Switch to inactive.
       this.active.set(false);
    }
}

k8s优雅停机

k8s在docker的再上层,优雅停机涉及到pod层面和网络层面

网络层面:

  1. Pod 被删除,状态置为 Terminating。

  2. Endpoint Controller 将该 Pod 的 ip 从 Endpoint 对象中删除。

  3. Kube-proxy 根据 Endpoint 对象的改变更新 iptables/ipvs 规则,不再将流量路由到被删除的 Pod。

  4. 如果还有其他 Gateway 依赖 Endpoint 资源变化的,也会改变自己的配置(比如 Nginx Ingress Controller)。

Pod 层面:

  1. Pod 被删除,状态置为 Terminating。

  2. Kubelet 捕获到 ApiServer 中 Pod 状态变化,执行 syncPod 动作。

  3. 如果 Pod 配置了 preStop Hook ,将会执行。

  4. kubelet 对 Pod 中各个 container 发送调用 cri 接口中 StopContainer 方法,向 dockerd 发送 stop -t 指令,用 SIGTERM 信号以通知容器内应用进程开始优雅停止。

  5. 等待容器内应用进程完全停止,如果容器在 gracePeriod 执行时间内还未完全停止,就发送 SIGKILL 信号强制杀死应用进程(容器运行时处理)。

  6. 所有容器进程终止,清理 Pod 资源。

Spring的优雅停机

深入到pod内,Spring与JVM是如何进行优雅停机的呢?

  1. kubelet 发送 SIGTERM 信号给 Pod 内的容器主进程(PID 1)。

  2. 应用进程收到 SIGTERM 信号,开始执行应用停机

  3. Spring在JVM注册了停机钩子,因此Spring会先于JVM停机完成上下文的停机

  4. Spring完成停机后,回调JVM方法,JVM停机前完成最后的资源清理,把申请的资源全部归还,释放

Spring如何注册停机钩子

如果是SpringBoot,首先SpringBoot监听SIGTERM信号,能够在收到SIGTERM时开始停机流程,同时SpringBoot也向JVM注册了钩子

如果是传统的SpringFramework框架,则需要我们手动注册停机钩子:

方法一. 使用Tomcat的ServletContextListener监听Tomcat的停机

<!-- web.xml -->
<listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

因为Spring的ContextLoaderListener已经实现了Tomcat的Servlet逻辑,因此停机的时候也完成了生命周期管理,同时注册了钩子

这个方式就比较适合成熟的大项目,因为实际项目往往就是基于Tomcat的web应用

方法二. 手动注册上下文钩子

// 在应用启动时
ConfigurableApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
context.registerShutdownHook(); // 注册JVM关闭钩子

使用这种方案的一般都是自己的小项目,一般直接用xml上下文启动的时候,是没有注册钩子的,就需要我们手动调用该方法完成

Spring完成上下文停机的流程

简单来说,就是如下步骤:

  1. 判断bean是否已经实例化,没有,则清理三级缓存即可

  2. 如果bean已经实例化,如果是prototype类型,不需要处理,如果是单例,进行销毁

  3. 对于单例,检查bean是否具有生命周期管理方法,没有,则直接清理二级缓存即可,如果有,则执行destroy流程

一般来说,纯逻辑bean,不实现生命周期管理也无所谓,可用直接回收掉。但是一些跟连接池相关的bean,最好进行生命周期管理,例如数据库的连接池

bean实现生命周期管理的方法有:

1 实现DisposableBean接口

@Component
public class DatabasePool implements DisposableBean {  // ✅ 实现了DisposableBean
    @Override
    public void destroy() throws Exception {
        // Spring会调用这个方法
        closeConnections();
    }
}

2 使用@PreDestroy注解

@Component
public class MessageConsumer {
    
    @PreDestroy  // ✅ 有@PreDestroy注解
    public void gracefulShutdown() {
        stopMessageListening();
    }
}

3 配置destory-method

<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" 
      destroy-method="close"/>  <!-- ✅ 配置了destroy-method -->

以上逻辑可用基于spring源码来看:


// org.springframework.context.support.AbstractApplicationContext
public void close() {
	synchronized (this.startupShutdownMonitor) {
		doClose();
		// If we registered a JVM shutdown hook, we don't need it anymore now:
		// We've already explicitly closed the context.
		if (this.shutdownHook != null) {
			try {
				Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
			}
			catch (IllegalStateException ex) {
				// ignore - VM is already shutting down
			}
		}
	}
}

protected void doClose() {
    // 1. 设置活跃状态为false
    this.active.set(false);
    
    // 2. 发布ContextClosedEvent事件
    publishEvent(new ContextClosedEvent(this));
    
    // 3. 销毁所有单例Bean(不管它们是否具备销毁能力)
    destroyBeans();
    
    // 4. 关闭BeanFactory
    closeBeanFactory();
    
    // 5. 回调子类的onClose()
    onClose();
}

doClose方法首先发ContextClosedEvent通知业务尽快完成停机操作,然后销毁单例bean,最后关闭beanFactory

那么核心的destroyBeans,向下跟进:

// org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#destroySingletons
public void destroySingletons() {
    if (logger.isInfoEnabled()) {
       logger.info("Destroying singletons in " + this);
    }
    synchronized (this.singletonObjects) {
       this.singletonsCurrentlyInDestruction = true;
    }
    synchronized (this.disposableBeans) {
       String[] disposableBeanNames = StringUtils.toStringArray(this.disposableBeans.keySet());
       for (int i = disposableBeanNames.length - 1; i >= 0; i--) {
          destroySingleton(disposableBeanNames[i]);
       }
    }
    this.containedBeanMap.clear();
    this.dependentBeanMap.clear();
    this.dependenciesForBeanMap.clear();
    synchronized (this.singletonObjects) {
       this.singletonObjects.clear();
       this.singletonFactories.clear();
       this.earlySingletonObjects.clear();
       this.registeredSingletons.clear();
       this.singletonsCurrentlyInDestruction = false;
    }
}

可见对于单例bean,spring已经找出了他们是否是全生命周期管理的,对于这种bean,先执行destroySigleton方法完成生命周期

然后完成三级缓存的清理和bean map的清理,如果没有实现生命周期方法,那就直接清理缓存了

Spring优雅停机陷阱

当Spring完成缓存清理后会发生什么呢?

当spring完成缓存清理,这个bean就变成了GCRoot不可达,能够被JVM回收,同时,ApplicationContextAware#getBean方法就无法再拿到这个bean了

但是这时,spring还没有调用钩子方法通知JVM停机,在这个时间差内,如果一个非spring托管的bean,或者一个静态工具类,基于ApplicationContextAware#getBean获取bean,就有可能会返回null,如果不try-catch执行对应方法就会报空指针异常,而如果try-catch了就很可能使业务发生错误

也就是说,如果业务代码在非spring托管的bean中使用SpringBean的话,在优雅停机期间处理的最后一点流量是有概率出现问题的

这也是Spring优雅停机过程中的一个陷阱

参考文档

  1. https://www.cnblogs.com/zhangmingcheng/p/18254613

  2. https://www.cnblogs.com/luciochn/p/14878160.html

0

评论区