ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

容器化服务优雅停机验证:从K8s终止流程到测试实践

容器化服务优雅停机验证:从K8s终止流程到测试实践 做软件测试这些年我见过太多线上事故表面上是网络抖动、数据库慢查询追到根因才发现是发布或扩缩容时容器被直接杀掉服务没来得及把自己手里的活干完。容器化服务的优雅停机验证就是把这件平时没人注意、一上线就出事的事情变成一套可以稳定复现、能量化指标、能写进回归体系的测试实践。优雅停机验证通俗说就是容器收到终止信号后先停止接收新请求把手头正在处理的请求处理完释放连接和资源再从容退出。但这个“从容”在容器环境里非常难因为K8s有一套自己的终止流程应用、镜像、探针、负载均衡任何一个环节不配合优雅停机就退化成暴力强杀。这篇文章适合所有接触过容器化服务的测试工程师尤其是负责接口测试、性能测试、发布质量保障的同学。我会把我在实际项目中踩过的坑、验证过的实验、沉淀下来的检查项完整梳理一遍你可以直接照着落地。1. 为什么测试要盯上优雅停机很多测试同学的第一反应是停机有什么好测的服务能起、接口能通、发布能过不就行了这个想法在单体时代勉强成立但在容器化时代就是给自己埋雷。1.1 线上事故的根因往往藏在“停止”里先复盘一个我经历过的典型事故。某个交易系统做滚动发布3个副本逐个更新前两个都没事第三个副本在流量高峰期被摘掉后监控立刻报了一串500和502。开发查了半天最后发现服务在收到终止信号后直接调用系统退出压根没有等待正在进行的交易请求处理完成。数据库那边出现了一批未完成的写事务用户的下单请求有一小部分没有真正落库但前端已经显示成功。这种事故本质上不是代码逻辑错误而是生命周期管理缺失。你的服务有没有在收到SIGTERM后先摘流量、再处理存量请求、最后关闭连接池这个链路如果不测线上全靠运气。而且容器化之后“进程退出”这件事的复杂度被K8s放大了——不是你CtrlC一下就完了它涉及到Pod状态、Endpoints摘除、探针、优雅终止期限、sidecar等多个环节。1.2 容器环境的“暴力停机”和“优雅停机”到底差在哪我把两者放在一起对比方便你理解测试时该关注哪些差异点。所谓暴力停机就是进程收到终止信号后不做任何清理直接退出。优雅停机则是先处理完存量请求、释放资源、再退出。对比维度暴力停机优雅停机终止信号对SIGTERM无感知或直接忽略后被杀监听SIGTERM进入shutdown流程处理中请求立即中断客户端收到连接重置等待处理完成返回正常响应新请求停止前最后一刻可能仍被路由到先从负载均衡摘除再停止接收连接池/线程池直接释放正在用的连接被打断优雅关闭归还连接拒绝新任务数据一致性事务可能中断状态不一致存量事务处理完落库完整日志可观测性日志中断无shutdown记录有完整的shutdown顺序日志退出码可能被强杀非预期退出码正常退出退出码0容器世界里更残酷的是如果你的进程不处理SIGTERMK8s会在优雅终止时限到期后直接发SIGKILL进程没有任何机会做清理。这就相当于领导给你打电话说“给你30秒交接”你还在打游戏没听见30秒后电话直接被挂断数据也带走了。1.3 K8s终止一个Pod的真实流程这里有必要把K8s终止Pod的完整时序讲清楚因为我们的测试设计都是围绕这个时序来的。你执行kubectl delete pod或滚动更新淘汰旧副本时K8s的kubelet会执行以下步骤Pod状态切换为Terminating从Service的Endpoints中摘除该Pod的IP新的流量不再路由过来。kubelet触发preStop钩子如果配置了执行Hook里的命令或HTTP请求。这是你最后的机会比如通知注册中心下线、把指标打点、睡几秒等。kubelet向Pod内容器的主进程发送SIGTERM信号。进入优雅终止倒计时默认30秒由terminationGracePeriodSeconds控制。如果倒计时结束进程还没退出kubelet发送SIGKILL强杀。Pod被彻底删除。这个流程里步骤2和步骤4是测试最容易出问题的地方。preStop钩子常被用来“睡觉”sleep几秒来等待负载均衡摘流但如果你钩子睡太久占用的也是优雅终止时间反而压缩了应用清理的时间。很多团队配置了preStop sleep 10秒结果terminationGracePeriodSeconds只有15秒应用自己只剩5秒去处理存量请求那就必然出问题。2. 测试设计先想清楚“优雅”怎么定义成指标做测试最忌讳的是拿“感觉”当标准。优雅停机听起来是质量标准但落到测试用例里它必须被翻译成可量化、可断言的指标。2.1 把“优雅”翻译成几个可验证的断言我在做优雅停机验证时会先定义一组验收断言每条都能用脚本或日志去检查而不是靠肉眼观察终止信号发出后处理中的请求100%正常返回客户端无连接重置、无超时、无5xx。终止信号发出后服务不再接受新请求已有连接不再建立新事务。线程池/连接池在进程退出前被优雅关闭没有泄漏日志。服务进程在收到SIGTERM后能够在配置的优雅终止期限内自行退出退出码为0。停机过程中日志没有丢失shutdown相关日志完整落盘。如果涉及消息消费消费者停止后未处理完的消息不会被确认下次启动能继续消费。这组断言看起来简单但每一条背后都有对应的测试手段。请求正常返回要靠压测客户端记录不再接受新请求要靠服务日志里的连接拒绝记录线程池回收要靠应用日志退出码要靠K8s的Pod状态和容器日志。2.2 按场景拆解测试矩阵优雅停机不是单一场景它发生在很多种情况下场景不同测试重点也会不同。我建议按终止触发方式和流量类型两个维度来拆矩阵。终止触发方式上至少覆盖四种手动删除Pod模拟节点维护或Debug、滚动更新中的缩容、kubectl drain节点维护、HPA缩容。这四种里滚动更新最常发生缩容时最容易出问题因为流量还在、副本数在减少。流量类型上至少要覆盖HTTP短连接请求、HTTP长连接/keep-alive请求、WebSocket长连接、消息队列消费、gRPC流式请求。不同流量类型的收尾行为差别很大HTTP短连接处理起来最省事长连接和WebSocket难度最大因为现有的连接在Pod被摘除后往往还在服务端挂着处理不当就会出现“连接还在、请求已断”的诡异现象。我在实际项目里还会做一个“单副本测试”和“多副本测试”的区分。单副本看的是应用本身的生命周期处理能力多副本看的是负载均衡和Endpoints摘除的配合。很多服务在单副本下表现很好多副本下就因为流量切换不及时而出问题。2.3 设计一张可勾选的验收表为了不让验证流于形式我习惯做一张表格每测一个场景就往里填结果。实测下来这张表既是测试执行记录也是发布评审时的证据。场景终止触发方式流量类型预期结果实测结果是否通过单副本HTTP短连接删除PodHTTP短连接存量请求100%成功退出码0--多副本HTTP长连接滚动更新缩容HTTP keep-alive摘除后连接被服务端回收无RST--WebSocket连接节点drainWS消息服务端发送close帧后退出--消息消费场景HPA缩容Kafka/RabbitMQ已拉取未确认消息正确处理--这张表还可以加一列“观察到的现象”比如“收到SIGTERM后3秒内完成线程池回收”这种时间维度的记录。时间维度很重要我后面专门讲。3. 测试环境与压测工具的准备优雅停机验证不是纯功能测试它对环境、工具、观测手段都有要求。你不可能在生产环境随便删Pod来测试所以一定要有一个可控的容器环境。3.1 环境选型和工具清单本地开发环境我推荐用kind或k3s它们都能在笔记本上跑出一个完整的K8s集群行为跟生产环境基本一致。如果你公司已经有测试环境的K8s集群直接用测试环境更贴近真实但要注意给这个测试留出独立的命名空间避免影响其他人的联调。工具方面我列出我常用的组合压测流量生成wrk或ab简单场景、k6复杂场景、脚本化断言、自写Python脚本记录每个请求的时间戳和结果。Pod状态观测kubectl get pod -w、kubectl describe pod。服务日志采集kubectl logs --timestamps、kubectl logs -f、stern多Pod日志聚合。网络连接观测netstat、ss、tcpdump深入排查时用。指标看板Prometheus Grafana如果有现成的直接从连接数、QPS曲线看变化。工具不用太复杂关键是压测客户端要能记录到“每一个请求的结果和时间”。因为这决定了你能不能证明“请求100%成功”。3.2 造一个需要“收尾”的测试服务你需要一个真实的服务来验证优雅停机这个服务最好满足两个条件一是接口里带有耗时操作比如sleep几秒模拟业务处理二是能够打印详细的shutdown日志。这里我给一个简单Java Spring Boot的例子用PreDestroy和Thread.sleep模拟存量请求的处理过程SpringBootApplication public class GracefulShutdownApp { RestController static class DemoController { GetMapping(/slow) public String slow() throws InterruptedException { // 模拟处理耗时5秒的业务请求 Thread.sleep(5000); return done; } } PreDestroy public void preDestroy() { System.out.println([SHUTDOWN] start at System.currentTimeMillis()); try { // 模拟等待线程池处理完存量请求 Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println([SHUTDOWN] done at System.currentTimeMillis()); } public static void main(String[] args) { SpringApplication app new SpringApplication(GracefulShutdownApp.class); // 关键开启Spring的优雅停机默认是immediate不会等处理中的请求 app.setShutdownHookBehavior(SpringApplicationShutdownHookBehavior.GRACEFUL); app.run(args); } }这里的关键是app.setShutdownHookBehavior(...)以及server.shutdowngraceful配置。很多服务在测试时发现“优雅停机无效”就是因为Spring Boot默认的停机行为不是优雅的Spring会直接关掉容器不会等线程池里的任务跑完。这一点非常容易被忽略我建议你拿到任何Java服务的优雅停机测试先看这个配置。如果是Go写的高性能服务处理SIGTERM的方式通常是手动捕获信号然后调用http.Server.Shutdown()这里不展开原理一样捕获信号、摘流量、处理存量、退出。3.3 压测客户端怎么选压测客户端的核心要求是能记录每个请求的起止时间和结果不能只给一个聚合的吞吐率。因为优雅停机验证要看的是“SIGTERM发出后正在飞的请求有没有断”。wrk虽然快但它的统计是聚合的不方便按时间切分。我更喜欢用脚本。这里给个Python脚本用requests线程池持续打流量把所有请求的结果和耗时记录到CSV停机测试后分析这个文件就很直观import threading import time import requests import csv stop False result_file requests.csv base_url http://service-ip/slow def attack(idx): while not stop: start time.time() try: r requests.get(base_url, timeout10) success 1 except Exception as e: success 0 end time.time() with open(result_file, a, newline) as f: writer csv.writer(f) writer.writerow([idx, start, end, success, end - start]) time.sleep(0.02) threads [] for i in range(20): t threading.Thread(targetattack, args(i,)) threads.append(t) t.start() # 等外部按任意键停止 input() stop True for t in threads: t.join()这个脚本不复杂但它的价值在于你执行kubectl delete pod之后可以清楚看到哪个时间点之前的请求是成功的、哪个时间点之后的请求开始失败或超时。注意脚本要和K8s集群的时钟保持同步不然SIGTERM时间和请求时间对不上分析会很痛苦。4. 完整的验证实验一次“优雅停机验证”的现场记录理论说完我把一次标准的验证过程完整走一遍。这个实验是从真实项目中抽象出来的你照着做就能得到可复现的结果。4.1 从压测到删除Pod的标准操作流程实验条件K8s集群测试服务1个Pod配置了terminationGracePeriodSeconds: 30服务开启优雅停机接口/slow处理耗时5秒。压测客户端用上面的Python脚本20线程持续打流量。第一步确认Pod处于Running状态服务接口可通kubectl get pod -n test kubectl port-forward pod/pod-name 8080:8080 -n test curl http://127.0.0.1:8080/slow第二步启动压测脚本让流量稳定跑起来。观察requests.csv里每行都是成功的耗时稳定在5秒左右。第三步另开一个终端执行删除Pod的命令。这里我建议记录下删除命令执行的时间date %s.%N kubectl delete pod pod-name -n test删除命令执行后立刻切到kubectl get pod -w -n test的窗口你会看到Pod状态从Running变成Terminating然后过几秒消失。第四步在Pod彻底消失前抓它的日志。注意要用--timestamps带上时间戳后面分析才有依据kubectl logs pod-name -n test --timestamps shutdown.log第五步压测持续到Pod消失后30秒确认没有新的报错停止脚本。开始分析。4.2 关键时间线怎么读日志和请求记录这是整个验证实验最见功力的地方。你要把三份数据的时间线对齐K8s的Pod状态变化、应用日志里的shutdown记录、压测客户端记录到的每个请求结果。我通常会画一条时间线按顺序标注删除命令发出时间T0Pod进入Terminating时间T1应用收到SIGTERM时间T2日志里会打印线程池回收完成时间T3进程退出时间T4Pod从集群消失时间T5。然后在压测记录里找T2之后发起的请求看它们的结束时间是在T3之前还是之后结果是否成功。在这个实验里正常情况下你会看到T2之后服务虽然还活着但Spring Boot的优雅停机逻辑已经不再接受新请求压测脚本在T2之后发起的请求会失败或拒绝连接。关键在于T2之前发起、T2之后才返回的“在途请求”它们的成功率和耗时才是优雅停机是否生效的核心指标。如果这些请求全部成功返回耗时依然是5秒左右说明存量请求被好好处理了。如果这些请求出现连接重置、超时、5xx那说明应用的收尾逻辑有问题。实操中我踩过一个坑压测脚本的请求如果设置了很短的超时比如3秒那些耗时5秒的在途请求会先被客户端超时判失败但服务端其实处理成功了。所以这里超时时间一定要大于接口耗时不然测试结果会虚报问题。我把超时设置为接口最大耗时的两倍。4.3 对照组实验优雅停机失败是什么样子为了让你心里有数我再描述一组对照实验故意让优雅停机失效看观测数据怎么变。这组实验在面试和团队培训时讲出来说服力很强。对照一应用不处理SIGTERM。把server.shutdowngraceful去掉重新部署重复实验。你会发现Pod进入Terminating后应用没有任何shutdown日志进程不会主动退出直到30秒的terminationGracePeriodSeconds耗尽被SIGKILL强杀。这30秒里一部分在途请求被正常处理但还没开始处理的请求全部被中断客户端大量超时和连接重置。日志里没有“shutdown done”这样的收尾记录只有强杀后的状态码137。对照二把terminationGracePeriodSeconds设为5秒业务接口耗时5秒优雅停机配置正常。这时你会发现应用还在处理存量请求5秒期限就到了被SIGKILL强杀。在途请求断了一半。这就是我前面说的优雅终止期必须大于请求最大处理时间加缓冲不然再优雅也会被砍头。对照三在preStop钩子里sleep 10秒但优雅终止期只有12秒。应用收到SIGTERM后只剩2秒去处理存量请求结果大批请求被丢弃。这个案例在团队里特别有教育意义——很多人以为preStop sleep就万事大吉实际上它在消耗应用自己的清理时间。这些对照实验做完你对优雅停机的理解会是立体的。以后再碰到线上偶发报错你脑子里会瞬间闪过“是不是优雅停机没生效”这个判断。5. 常见问题与排查技巧实录优雅停机验证做多了会遇到各种奇怪的现象。我把自己踩过坑和帮别人排查过的问题整理成一份速查表按症状、根因、排查手段列出方便你现场对照。5.1 请求还是断了优雅停机像是失效了这是最常见的反馈。但排查下来分好几种情况不能一概而论。第一种是应用根本没监听信号。很多服务是在裸进程里跑或者镜像里的入口进程是bash/shellSIGTERM只发给了PID 1的shell业务进程收不到。因为容器里PID 1通常不代表业务进程如果你用类似CMD [sh, -c, java -jar app.jar]的方式启动SIGTERM会被shell接收Java进程不一定会收到。我建议所有生产服务用tini或直接让应用进程担任PID 1或者在启动命令里处理好信号转发tini -- java -jar app.jar第二种是Endpoints摘除不够快。Pod虽然进入Terminating了但有些客户端SDK或网关有连接缓存还在往旧IP打流量。这种情况不是服务端问题而是流量治理层面没做好。排查时用kubectl get endpoints -n test看Pod IP是否及时被移除。第三种是长连接没有额外的关闭机制。HTTP长连接在服务端关闭前如果不主动断开存量连接客户端的请求会一直悬挂。排查时用ss -tnp看服务端连接状态如果Terminating过程中还有大量ESTABLISHED连接说明应用没有回收这些连接。5.2 日志丢了shutdown记录没落盘优雅停机验证的一致性要求日志不能丢但很多服务在强杀时确实会丢日志。根因多半是日志框架用了异步appender日志先写进内存队列还没flush到文件就被SIGKILL了。排查手段是看Pod消失后日志文件最后几行的内容是否完整。这类问题测试时就要发现不然线上排查事故会非常痛苦。解决方案是给异步日志增加shutdownHook里的flush或者在优雅停机方法里显式调用日志框架的关闭方法。另一个方案是依靠容器日志驱动让应用只往stdout写日志由容器运行时负责采集这样强杀前K8s也能把已有输出收集走。5.3 停机时探针还在打导致服务反复重启Pod进入Terminating后如果你把readinessProbe和livenessProbe都配置得很激进探针可能还在执行如果探针认为服务不健康会触发重启或重建干扰优雅停机过程。比较好的做法是在preStop钩子里通知探针进入“停止探测”状态或者让探针的逻辑识别到正在关闭的标记直接返回成功。这个坑不好排查因为现象是Pod异常反复重启日志里没有明确的shutdown顺序。我会建议在测试场景里刻意把探针的failureThreshold和periodSeconds调大排除这个干扰因素后再验证核心逻辑。否则你测出的失败可能是探针和优雅停机互相拉扯导致的不是应用本身的问题。5.4 多副本场景下新连接还在往Terminating的Pod上打这是另一个高频问题。虽然Endpoints摘除了但有些负载均衡器尤其是自建的nginx、HAProxy、客户端本地缓存不会立即感知到摘除需要等下一次服务发现刷新。你看到的表象就是Pod已经Terminating新请求还在往里打然后连接被拒绝或中断。这类问题排查起来比较费劲我一般会用tcpdump在服务Pod上抓包看Terminating之后还有没有SYN包进来。如果有就说明上游没有摘除。常见的解法是preStop钩子里sleep几秒等待上游服务发现刷新但要注意控制sleep时长不能挤占优雅终止期。更好的做法是在服务发现层面解决比如使用有平滑排空能力的Ingress Controller或Service Mesh。5.5 时间不同步导致时间线分析困难最后提一个比较隐蔽的问题。压测客户端、kubectl命令、服务Pod各自运行在不同机器上如果时钟有偏移你在分析时间线时会发现请求时间和服务端日志时间对不上没法判断“请求到底有没有在shutdown开始前进入服务”。我在实测中吃过这个亏。当时客户端机器的时间比Pod所在节点快了几十秒导致看起来所有的请求都发生在shutdown日志之后差点误判为“服务在关闭后才收到请求”。解决办法是让所有时间源对齐NTP并且都使用毫秒级时间戳记录。更稳妥的做法是在请求里带一个request_id服务端接收和返回时都打印同一个request_id用请求的唯一标识去对齐而不是纯靠时间。6. 把优雅停机验证固化到日常测试体系里验证一次、排查一次解决的问题有限。真正有价值的是把优雅停机验证变成自动化、可回归的测试资产让每次发布、每个服务上线前都自动跑一遍。这也是我从测试设计到工程实践的体会。6.1 自动化演进从脚本回归到故障演练我的自动化方案分三个阶段。第一个阶段是手动触发、脚本采集也就是你照着第4节做一遍把结果存档。第二个阶段是脚本自动化用一段Python或Shell脚本依次完成部署、压测、删除Pod、采集日志、断言结果每一步都打印PASS或FAIL。这个阶段跑一次大约几分钟可以挂到CI/CD里在发布前作为必做的回归项。第三个阶段是使用混沌工程平台比如Chaos Mesh或Litmus直接把“杀Pod”定义成故障实验周期性地在测试环境执行断言Pod删除后的请求成功率。这样做的好处是能发现偶发性问题不是只在发布前测一次而是每周、每月持续验证。对核心服务我甚至建议在预发环境的低峰期也做一次确保真实场景下优雅停机仍然有效。这里我给一个最简单的自动化断言逻辑放在脚本里判断请求结果# 统计压测结果中失败请求的数量 awk -F, $40 {fail} END {print fail_countfail} requests.csv如果你的失败数超过0测试就标红。更进一步可以断言所有失败请求的时间是否集中在SIGTERM之后如果集中在之后说明是在途请求没有被服务端妥善处理如果分散在整个压测过程中说明是压测环境本身有网络问题。6.2 团队流程里加一道“停机检查”测试做得再好如果不在流程上卡住开发还是会忘记配置优雅停机参数。我建议团队把优雅停机验证纳入发布checklist至少包含以下几项服务是否注册了SIGTERM处理逻辑Java服务确认server.shutdowngraceful。镜像入口是否处理好PID 1的信号转发。terminationGracePeriodSeconds是否大于请求最大处理时间加缓冲。preStop钩子是否配置sleep时长是否合理是否挤占优雅终止期。探针配置是否会干扰停机过程。多副本下负载均衡和连接回收行为是否有验证记录。这几项做成一个模板每次服务发布前由测试或SRE打钩。别小看这个动作它能把大量线上事故拦截在发布前。我从几个大厂朋友那边了解到他们内部的核心服务发布检查项里有一半以上是和优雅停机相关的。6.3 一个测试工程师怎么把这件事讲出深度最后说一点面试和个人成长相关的话题。优雅停机验证是一个特别能体现测试设计能力的题目因为它涵盖了场景拆解、指标设计、工具使用、问题排查、自动化落地整个过程。如果你在面试中被问到“你怎么测K8s里服务的优雅停机”不要只回答“发个SIGTERM看日志”而是按这个逻辑讲先说明你如何定义验收标准即请求零丢失、退出码正确、日志完整、连接回收。再说明你如何设计场景即单副本/多副本、短连接/长连接/消息消费、手动删Pod/滚动更新/节点drain。然后举一个你实际发现过的bug比如preStop sleep挤占了优雅终止期导致在途请求被强杀或者PID 1不是业务进程导致SIGTERM没被处理。最后讲你怎么把它变成自动化回归。这套答法既有技术深度又有工程思维还能体现你踩过坑、解决过真问题。相比背“软件测试八股文”这种真实项目经验更能建立信任感。你要能顺手写一段Python脚本、讲清楚K8s Pod终止时序就更有说服力了。根据我个人的体会优雅停机验证最大的价值不是证明“我能杀一个Pod然后说他没事”而是倒逼研发团队把服务的生命周期管理当成一等公民来对待。很多团队的服务对“启动”非常熟悉对“终止”一无所知包括注册中心下线、连接池回收、消息消费位点提交、缓存刷新清理这些细节全都在停机瞬间暴露出来。把它变成一种测试习惯之后线上发布就不再心惊胆战。最终你会发现优雅停机验证测的不是“停机”而是这个系统从生到死的每一个生命周期环节有没有被认真设计过。
返回列表