容器化应用的信号处理:优雅关闭在 ENTRYPOINT 中的实现 容器化应用的信号处理优雅关闭在 ENTRYPOINT 中的实现SIGTERM 来了你的容器直接退出——数据库连接没断开文件没写入下次启动数据全丢了。一、场景痛点你的容器化应用在 K8s 中运行Pod 被驱逐时 K8s 发送 SIGTERM 信号。你的应用没有处理 SIGTERM进程直接退出。后果数据库连接没有关闭事务半提交、缓冲区数据没有 flush日志丢失、临时文件没有清理下次启动可能读到脏数据。你加了 SIGTERM 处理收到信号后调用db.close()和buffer.flush()。但关闭操作本身可能耗时 10 秒数据库连接池释放而 K8s 的terminationGracePeriodSeconds默认只有 30 秒。你的关闭操作在 30 秒内完成了但有些场景需要更长时间——比如正在处理的 50 个并发请求每个请求需要等数据库返回才能安全断开。核心矛盾优雅关闭不是立刻退出而是完成当前工作再退出——但完成工作需要时间K8s 的超时窗口有限。二、底层机制与原理剖析2.1 K8s Pod 关闭的信号序列2.2 信号类型与行为信号可拦截行为K8s 使用场景SIGTERM可拦截优雅终止请求Pod 关闭时发送SIGKILL不可拦截立即杀死进程超时后强制终止SIGINT可拦截中断CtrlC开发环境手动停止SIGHUP可拦截重新加载配置通常不用关键SIGKILL 无法拦截——一旦发送进程立即被杀没有任何清理机会。所以优雅关闭必须在 SIGKILL 之前完成即必须在terminationGracePeriodSeconds内完成。2.3 ENTRYPOINT 脚本的作用ENTRYPOINT 脚本是容器启动的第一个进程PID 1。K8s 发送 SIGTERM 时只发给 PID 1。如果你的 ENTRYPOINT 是 shell 脚本ENTRYPOINT [sh, -c, node app.js]shell 进程是 PID 1node 进程不是 PID 1——SIGTERM 发给 shellshell 不转发给 nodenode 永远收不到 SIGTERM。正确做法用 exec 模式让应用进程成为 PID 1。ENTRYPOINT [node, app.js]或 shell 脚本中用exec node app.js——exec 替换 shell 进程应用进程变成 PID 1。三、生产级代码实现3.1 Node.js 优雅关闭// graceful-shutdown.ts —— Node.js 应用优雅关闭处理 import { Server } from http; import { EventEmitter } from events; export class GracefulShutdown extends EventEmitter { private server: Server | null null; private dbConnection: any | null null; private activeRequests: SetPromiseunknown new Set(); private isShuttingDown false; private maxShutdownTimeMs: number; // 最大关闭时间 gracePeriod - 5s constructor(maxShutdownTimeMs: number 25000) { super(); // 最大关闭时间比 K8s 的 terminationGracePeriodSeconds 短 5 秒 // 5 秒余量用于 SIGTERM 处理到关闭开始的延迟 // 如果 gracePeriod30smaxShutdownTime25s this.maxShutdownTimeMs maxShutdownTimeMs; // 注册信号处理SIGTERM 和 SIGINT 都触发优雅关闭 // SIGTERMK8s 发送的优雅终止信号 // SIGINT开发环境 CtrlC process.on(SIGTERM, () this.handleShutdown(SIGTERM)); process.on(SIGINT, () this.handleShutdown(SIGINT)); } /** 注册 HTTP Server优雅关闭需要先停止接收新请求 */ registerServer(server: Server): void { this.server server; } /** 注册数据库连接关闭时需要释放连接池 */ registerDbConnection(db: any): void { this.dbConnection db; } /** 注册活跃请求关闭时等待所有活跃请求完成 */ trackRequest(promise: Promiseunknown): void { if (this.isShuttingDown) { // 关闭期间不再接收新请求直接拒绝 return; } this.activeRequests.add(promise); promise.finally(() this.activeRequests.delete(promise)); } /** 优雅关闭处理收到信号后的完整流程 */ private async handleShutdown(signal: string): void { if (this.isShuttingDown) { // 避免重复处理可能收到多个 SIGTERM return; } this.isShuttingDown true; console.info(Received ${signal}, starting graceful shutdown...); // Step 1: 停止接收新请求 // HTTP Server 停止监听新连接但已有连接继续处理 if (this.server) { this.server.close(); // 不再接受新连接 console.info(Server stopped accepting new connections); } // Step 2: 等待现有请求完成 // 设置超时不能无限等待超时后强制关闭 const shutdownDeadline Date.now() this.maxShutdownTimeMs; console.info( Waiting for ${this.activeRequests.size} active requests... ); try { // 等待所有活跃请求完成或超时 await Promise.race([ this.waitForActiveRequests(), this.createTimeout(shutdownDeadline), ]); console.info(All active requests completed); } catch (timeoutError) { console.warning(Shutdown timeout exceeded, forcing close); } // Step 3: 关闭数据库连接池 // 连接池释放正在进行的数据库事务提交或回滚 if (this.dbConnection) { try { await this.dbConnection.close(); console.info(Database connection pool closed); } catch (err) { console.error(Failed to close database connection:, err); } } // Step 4: 退出进程 // exit 0优雅关闭成功 // 如果超时强制关闭也是 exit 0K8s 只关心 Pod 是否退出不关心 exit code console.info(Graceful shutdown completed, exiting); process.exit(0); } /** 等待所有活跃请求完成 */ private async waitForActiveRequests(): Promisevoid { if (this.activeRequests.size 0) return; await Promise.allSettled([...this.activeRequests]); } /** 创建超时 Promise超过截止时间则 reject */ private createTimeout(deadline: number): Promisenever { const remainingMs deadline - Date.now(); return new Promise((_, reject) { setTimeout( () reject(new Error(Shutdown timeout)), Math.max(remainingMs, 0) ); }); } }3.2 Dockerfile ENTRYPOINT 配置# Dockerfile —— 生产级 ENTRYPOINT 配置 # 关键用 exec 模式让应用进程成为 PID 1 FROM node:20-alpine WORKDIR /app # 安装依赖生产模式只安装 production 依赖 COPY package.json package-lock.json ./ RUN npm ci --onlyproduction # 复制应用代码 COPY dist/ ./dist/ # 方案一推荐直接用 node 作为 ENTRYPOINT # node 进程是 PID 1SIGTERM 直接发给 node # node 的 SIGTERM 处理器可以正常工作 ENTRYPOINT [node, dist/main.js] # 方案二需要启动脚本时用 exec 让应用进程替换 shell # ENTRYPOINT [sh, -c, exec node dist/main.js] # exec 替换 shell 进程node 变成 PID 1 # 方案三错误示范shell 不用 exec # ENTRYPOINT [sh, -c, node dist/main.js] # shell 是 PID 1node 是子进程 # SIGTERM 发给 shellshell 不转发给 node # node 永远收不到 SIGTERM无法优雅关闭 # 健康检查确认应用正常运行 HEALTHCHECK --interval10s --timeout3s --retries3 \ CMD wget --no-verbose --tries1 --spider http://localhost:8080/health || exit 13.3 K8s Deployment 优雅关闭配置# deployment.yaml —— 优雅关闭配置 apiVersion: apps/v1 kind: Deployment metadata: name: web-app spec: replicas: 3 selector: matchLabels: app: web-app template: metadata: labels: app: web-app spec: # 优雅关闭超时60 秒比默认 30 秒更长 # 60 秒覆盖了5s 停止接收请求 50s 等待活跃请求 5s 关闭连接 terminationGracePeriodSeconds: 60 containers: - name: app image: registry.internal/web-app:v2.0 ports: - containerPort: 8080 env: - name: SHUTDOWN_TIMEOUT_MS value: 55000 # 关闭超时 gracePeriod - 5s 55s # 生命周期钩子preStop 阶段延迟 5 秒 # K8s 发 SIGTERM 前先执行 preStop hook # preStop 期间应用还在正常运行没有收到 SIGTERM # 延迟 5 秒让 K8s 的 Service 从 Endpoints 列表中移除 Pod # Service 移除后新请求不会路由到即将关闭的 Pod lifecycle: preStop: exec: command: [sh, -c, sleep 5] # 就绪探针Pod 关闭时标记为不就绪 # 不就绪后 Service 不再路由新请求到该 Pod readinessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 5 periodSeconds: 53.4 Go 语言的优雅关闭// graceful_shutdown.go —— Go 应用优雅关闭处理 package main import ( context log net/http os os/signal syscall time ) func main() { // 创建 HTTP Server server : http.Server{Addr: :8080} // 注册路由 http.HandleFunc(/health, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) // 信号通道监听 SIGTERM 和 SIGINT // syscall.SIGTERMK8s 发送的优雅终止信号 // syscall.SIGINTCtrlC 中断信号 sigChan : make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) // 启动 HTTP Server在 goroutine 中运行 go func() { log.Println(Server started on :8080) if err : server.ListenAndServe(); err ! http.ErrServerClosed { log.Fatalf(Server error: %v, err) } }() // 等待信号阻塞直到收到 SIGTERM 或 SIGINT sig : -sigChan log.Printf(Received signal: %v, starting graceful shutdown, sig) // Step 1: 停止接收新请求 // Server.Shutdown 会停止接受新连接但等待已有连接完成 // 设置超时55 秒比 terminationGracePeriodSeconds 少 5 秒 shutdownCtx, cancel : context.WithTimeout( context.Background(), 55*time.Second, // 最大关闭时间 ) defer cancel() // Step 2: 等待现有请求完成 关闭 Server if err : server.Shutdown(shutdownCtx); err ! nil { log.Printf(Server shutdown error: %v, err) } // Step 3: 关闭数据库连接如果有 // db.Close() 释放连接池 log.Println(Graceful shutdown completed) }四、边界分析与架构权衡4.1 preStop 延迟的必要性preStop 的 5 秒延迟是为了让 K8s Service 从 Endpoints 列表中移除 Pod。如果不延迟SIGTERM 和 Endpoints 移除可能同时发生——新请求已经不再路由到该 Pod但旧请求还在处理。这没问题但反向场景SIGTERM 先到Endpoints 还没移除会导致新请求路由到正在关闭的 Pod——应用已经不再处理请求了。4.2 关闭超时的边界场景如果你的应用有 100 个并发请求每个请求需要 5 秒完成总关闭时间 5 秒最慢的请求完成时间不是 500 秒所有请求串行完成。因为请求是并发处理的关闭时只需要等最慢的那个。但如果有一个请求需要 60 秒比如大文件上传terminationGracePeriodSeconds30 秒不够——请求会被 SIGKILL 强制中断。4.3 适用边界与禁用场景适用HTTP Server、有数据库连接的应用、需要 flush 数据的应用禁用无状态 Worker处理完当前任务就退出、一次性 Job不需要优雅关闭、开发环境快速重启4.4 PID 1 问题的其他解法除了 exec 模式还有一种方案用tini或dumb-init作为 PID 1。它们是专门的 init 进程负责转发信号给子进程。优势是支持多子进程场景比如一个容器跑 nginx php-fpm劣势是增加了依赖。五、结语容器化应用的优雅关闭核心是三点让应用进程成为 PID 1exec 模式或直接 ENTRYPOINT、处理 SIGTERM 信号执行关闭流程停止接收新请求→等待活跃请求→关闭连接池、在 terminationGracePeriodSeconds 内完成关闭。preStop 的 5 秒延迟保证 K8s Service 先移除 Pod 再发 SIGTERM。关闭超时 gracePeriod - 5s超时后进程被 SIGKILL 强制杀死。PID 1 问题用 exec 模式解决——shell 脚本中必须用exec node app.js替换 shell 进程。