在滚动重启、扩容缩容、故障演练这些日常动作里,真正让故障被放大的,往往不是重启本身,而是关闭的时序失控:LB 还没把流量摘干净,进程已经退出;对端悄悄断了,本地 socket 却还挂着 ESTABLISHED;正在处理的请求被硬生生掐断,客户端拿到一串 502。
很多人习惯三件套各做各的——kill 一下 TERM、内核打开 keepalive、LB 配个健康检查——但三段之间没有任何约定,于是出现摘除太晚、连接假活、重传堆积拖垮线程池这类偶发且难复现的问题。
本文分享一套可直接落地的联动方案:分层关闭状态机 + TCP 存活探测 + 负载均衡摘除闭环,从关闭顺序、探测参数到摘除回切,给出可复制的脚本、参数表与排障口径。
一、失控现场:一次重启里最常见的四类故障
一次看似普通的滚动重启,往往同时踩中这几类问题:
- 摘除与下线赛跑:LB 健康检查间隔 10 秒,进程 2 秒内退出,检查还没跑完连接就被重置。
- 半开连接假活:对端崩溃未发 FIN,本地连接仍显示 ESTABLISHED,写操作一直阻塞。
- 存量请求被掐断:进程收到信号立刻关闭监听口,正在执行的事务直接返回错误。
- 重传放大延迟:链路丢包后没有探测兜底,一个连接僵死 30 秒才超时,把线程池占满。
**核心结论:**四类故障的共同根因,是关闭时序与存活判定两套机制各自为战。
二、分层关闭:把退出涉及的资源排成四层
把一次退出牵扯到的资源自上而下排队,才能确定谁先动、谁后动:
| 层级 | 资源对象 | 关闭动作 | 建议超时 |
|---|---|---|---|
| L4 入口层 | 监听 socket、LB 后端 | 摘除后端、停止 accept | 2~5s |
| L3 连接层 | 已建立的长连接 | 逐个 FIN 或心跳踢出 | 10~30s |
| L2 任务层 | 队列与在途请求 | 排空队列、等待回调 | 15~60s |
| L1 依赖层 | DB、Redis、下游 RPC | 按引用反序 close | 3~10s |
- 谁先动:入口层最先摘,客户端流量从此不再进新活。
- 谁垫后:依赖层最后关,前面各层清空后才断数据库。
顺序不能颠倒:先关依赖会让在途请求立刻报错,先关入口才能保证慢慢清旧活。
**核心结论:**关闭顺序固定为入口 → 连接 → 任务 → 依赖,任何一层抢跑都会把错误暴露给客户端。
三、关闭状态机:用一段脚本固化优雅退出时序
四层顺序要真正生效,需要一段显式编排的退出脚本,下面这段适配 Linux + systemd 环境:
#!/usr/bin/env bash# 四层优雅关闭编排,适配 systemd ExecStopPost 或 K8s preStop 钩子set-eDRAIN=${DRAIN:-20}on_term(){echo'[1/4] 标记不健康,等待 LB 摘除';touch/tmp/not_ready;sleep5echo'[2/4] 停止接收新连接';kill-USR1"$APP_PID"echo"[3/4] 排空在途请求${DRAIN}s";sleep"$DRAIN"echo'[4/4] 关闭依赖并退出';kill-TERM"$APP_PID";wait"$APP_PID"}trapon_termTERMsleepinfinity&APP_PID=$!wait关键点在于sleep 5必须不小于 LB 的检查间隔,否则摘除动作还没被消费,进程已经跑路。信号触发 → 标记不健康 → 停止接新 → 排空存量 → 依赖收尾,每步之间都有显式等待,LB 与进程才对得上节拍。
**核心结论:**优雅关闭不是发一个 TERM,而是一条带超时的状态机流水线。
四、TCP 存活探测:三个内核参数决定灵敏度
打开 keepalive 只是开了个开关,真正决定探测行为的是这三个内核参数:
| 参数 | 含义 | 默认值 | 常见优化值 |
|---|---|---|---|
tcp_keepalive_time | 空闲多久后发第一个探测包 | 7200s | 60s |
tcp_keepalive_intvl | 相邻两次探测的间隔 | 75s | 10s |
tcp_keepalive_probes | 判死前允许的探测次数 | 9 | 5 |
判死耗时 = time + intvl × probes:默认配置要等 2 小时以上才能发现对端已消失,按优化值算则压缩到 60+10×5=110 秒。
下面这段命令临时生效并说明持久化位置,可直接放进启动脚本:
# 临时生效;持久化写入 /etc/sysctl.d/99-tcp.confsysctl-wnet.ipv4.tcp_keepalive_time=60sysctl-wnet.ipv4.tcp_keepalive_intvl=10sysctl-wnet.ipv4.tcp_keepalive_probes=5**核心结论:**三个参数一次调完,TCP 层的故障感知就能从小时级降到分钟级,再快就要靠应用层心跳。
五、应用层心跳:给探测补上语义级判断
内核探测只能证明网络可达,服务是否还干活得由应用自己回答:
- 探测盲区:线程池打满、GC 长暂停时端口照常应答,TCP 层完全无感。
- 心跳设计:固定间隔发
PING,对端回PONG,连续 miss 达阈值即判定死亡。 - 阈值取舍:miss × interval 要大于 LB 检查周期,避免一次抖动就触发摘除。
下面这段 Python 心跳探测器可独立运行,适配任意 TCP 服务:
importsocket,timeclassHeartbeat:def__init__(self,host,port,interval=5,misses=3):self.addr,self.interval,self.misses=(host,port),interval,misses self.alive,self.miss_count=True,0defrun_once(self):try:s=socket.create_connection(self.addr,timeout=2)s.sendall(b'PING\n')ok=s.recv(4)==b'PONG's.close()exceptOSError:ok=Falseself.miss_count=0ifokelseself.miss_count+1ifself.miss_count>=self.misses:self.alive=False# 交给摘除流程returnself.alive网络通 ≠ 服务活,心跳是唯一能穿透到业务语义层的探针。
六、连接排空:让在途请求跑完再断开
摘除只是挡住新流量,真正优雅的是把已建立连接上的活干完:
- 问题:收到退出信号立即 close 监听口,客户端正在写入的请求直接断连。
- 治理:先停新请求,再给在途请求一个限时窗口,窗口结束才关闭连接。
下面这段 asyncio 服务器演示三段式排空,适配 Python 3.10+:
importasyncio,signal draining,inflight=False,set()asyncdefhandle(reader,writer):globaldraining task=asyncio.current_task()inflight.add(task)try:whileTrue:line=awaitreader.readline()ifnotline:breakifnotdraining:writer.write(b'OK\n')# 排空期只回存量awaitwriter.drain()finally:inflight.discard(task)writer.close()asyncdefon_term(*_):globaldraining draining=True# 1. 停新awaitasyncio.sleep(10)# 2. 排空窗口awaitasyncio.gather(*inflight,return_exceptions=True)# 3. 关旧asyncio.get_running_loop().stop()asyncdefmain():server=awaitasyncio.start_server(handle,'0.0.0.0',8080)asyncio.get_running_loop().add_signal_handler(signal.SIGTERM,on_term)asyncwithserver:awaitserver.serve_forever()asyncio.run(main())停新 → 排空 → 关旧,把突然断连变成平滑收尾。
七、健康检查与摘除:负载均衡侧的判定链路
LB 判断一个后端还能不能接活,通常是这几步串起来的:
| 检查类型 | 触发方式 | 判死条件 | 典型场景 |
|---|---|---|---|
| TCP 端口检查 | 三次握手 | 连续 3 次失败 | 四层 LB |
| HTTP 状态检查 | GET /healthz | 连续 2 次非 200 | 七层 LB |
| 主动摘除 API | 脚本或运维调用 | 立即生效 | 发布窗口 |
心跳失败 → 连续计数 → 摘除后端 → 等待排空 → 进程退出
阈值选取有个硬约束:fails × interval 必须小于进程排空时间,否则流量还没切走人已经下线,摘除形同虚设。
**核心结论:**LB 侧只负责挡住新流量,真正的存量清理仍要靠进程自己完成。
八、四层与七层负载:转发差异如何影响关闭策略
LB 工作在不同层级,下线时能拿到的信息量完全不同:
- 四层直通:SYN 透传、不解包,只能靠端口探测判断存活,感知不到业务错误。
- 七层代理:持有完整连接,可按状态码精细摘除、失败重试与请求重写。
- 会话保持:粘滞会话下摘除单实例会丢上下文,必须配合会话外置才能安全切换。
问题:四层下客户端与后端事实直连,摘除后存量连接不会自动迁移。
治理:配合短连接、快速探测与客户端重试,把切换成本压到一次重连以内。
**核心结论:**层级越高摘除越精细,但代理自身的连接与内存成本也越高,按请求特征选型。
九、联动闭环:探测结果驱动摘除与回切
探测、摘除、退出三段必须写进同一个常驻进程才闭环,下面这段 Python 可直接接进健康检查协程:
importsys,time,requests FAILS,INTERVAL,DRAIN=3,5,20LB_API,NODE='http://lb.internal/api/v1/backend','svc-a:8080'miss=0whileTrue:try:ok=requests.get('http://127.0.0.1:8080/healthz',timeout=1).status_code==200exceptrequests.RequestException:ok=Falsemiss=0ifokelsemiss+1ifmiss>=FAILS:# 连续失败判死requests.post(LB_API+'/remove',json={'node':NODE},timeout=3)time.sleep(DRAIN)# 等存量请求跑完sys.exit(0)# 交回优雅关闭流程time.sleep(INTERVAL)- 主动摘除优于被动等待:进程自己知道自己要走,抢在 LB 判死前上报。
- 回切同样重要:恢复健康后调用
/add重新注册,避免实例长期停摆。
**核心结论:**探测判死 → 主动摘除 → 排空等待 → 退出交接,四步一气呵成,不再依赖人工掐点。
十、可观测与排错:让下线过程留下证据
出问题时要能回答卡在哪一步,这几条指标与命令先备好:
- 关键指标:摘除耗时、排空耗时、探测失败计数、进程退出后的残余连接数。
- 采样口径:每次发布记录这四个数,横向对比才能发现劣化趋势。
| 现象 | 可能环节 | 排查命令 |
|---|---|---|
| 下线瞬间出现 502 | 摘除晚于进程退出 | ss -tan | grep :8080 |
| 连接长期僵死 | keepalive 未生效 | sysctl net.ipv4.tcp_keepalive_time |
| 单个请求卡 30s | 丢包后重传堆积 | ss -ti查看 retrans 字段 |
**核心结论:**可观测不是加分项,它是判断关闭时序到底有没有生效的唯一依据。
结语
把分层关闭、TCP 存活探测、负载均衡摘除三件事放进同一个闭环后,下线从一个充满不确定性的动作,变成了一条可观测、可回滚、可复用的流水线:入口先摘、连接慢关、依赖垫后,探测参数负责把故障发现时间从小时压到分钟,摘除 API 则负责在进程退出前把流量切干净。
落地建议按三步走:先统一退出脚本,把四层顺序固化下来;再调 keepalive 与心跳阈值,明确判死口径;最后把 LB 摘除与回切接口接进流程,并为摘除耗时、排空耗时建立发布基线。
关得有顺序、探得够快、摘得及时,服务下线才能从故障源变成常规操作。