Python实现服务暴力重启:进程强杀与健康检查全流程
说实话看到python_重启(暴力)这个标题我第一反应是——这哥们儿大概是被某个假死服务折磨得够呛了。搞开发或者运维的人应该都有这种经历某个服务进程卡死了端口不释放PID文件写得乱七八糟手动kill半天还提示拒绝访问或者进程不存在最后实在没辙直接上大招。所谓暴力重启其实不是什么黑魔法就是用Python写一套脚本把找进程、杀进程、拉起来、查状态这四件事串起来用强制手段让一个不正常的服务恢复工作。这套东西我用了好几年中间踩了不少坑今天把思路、代码、经验一次性整理出来希望能给正在被服务假死折磨的朋友们一点参考。1. 为什么需要暴力重启而不是手动操作1.1 所谓暴力到底是指什么很多人一听暴力就以为是写一堆危险的kill命令其实不是。这里的暴力指的是不等待优雅退出、直接强杀进程的处置方式。正常情况下服务退出有两条路径优雅退出进程收到SIGTERM信号先处理完手头的事情保存状态关闭连接然后自己退出。这个过程可能需要几秒甚至几十秒而且依赖于进程本身的实现是否靠谱。暴力退出直接发SIGKILL信号内核立刻回收进程资源不给你任何清理的机会。优雅退出看起来很美好但现实是——很多服务根本写不好优雅退出的逻辑。比如某些老的Java应用、跑飞了的Python脚本、卡在死锁里的数据库连接池你发SIGTERM信号过去它就像没听见一样该卡还是卡。这时候暴力重启反而是最有效的方案。其实你用Python写这个脚本本质上是把你手动操作的那套流程自动化打开任务管理器找进程、右键结束任务、再启动服务、还要时刻盯输出日志——这套动作如果每天重复个三五次谁都会想写个脚本一键搞定。1.2 面试不问但实战非常关心的优雅重启和强杀重启的适用场景先说结论不是所有场景都适合暴力重启。我踩过最有印象的一次坑是有个数据同步服务我直接kill -9了结果它正在写一半的本地队列文件全坏了重启后花了两个小时做一致性校验才算恢复。从那以后我就学乖了分场景处理。下面这张表格是我多年总结的判断依据场景推荐方式原因Web服务无状态接口暴力重启状态不落地强杀无副作用有本地队列/临时文件的服务先优雅后暴力强杀容易损坏未落盘的数据数据库类服务必须优雅强杀可能导致数据文件损坏CPU跑满卡死的脚本暴力重启优雅退出根本没机会执行内存泄漏进入假死状态的服务暴力重启此时SIGTERM处理逻辑可能已经不响应了实践中的做法是先给SIGTERM等8到10秒如果进程还在再给SIGKILL。这个策略既能尽量保住数据又能保证服务一定被干掉两头都能兼顾。2. 脚本核心设计思路与拆解2.1 三件事找进程、杀进程、拉起来整个暴力重启脚本的骨架其实就是三个环节。第一是找进程。这一步看似简单实际上有不少细节。按进程名找的时候经常发生的问题是根据名字搜出了一堆不相关的进程。比如你搜java结果所有Java应用都被搜出来了你根本没办判断哪个是你需要的。所以我在脚本里做了一个配置项的区分process_name精确匹配进程名command_line_keyword匹配完整命令行里的关键词pid_file直接读PID文件优先级是pid_file最准确command_line_keyword次之process_name最宽松也最容易误杀。第二是杀进程。注意杀进程不是简单地调用kill()就完事了。你要考虑如果进程不存在怎么办如果没有权限怎么办如果杀完但端口还没释放怎么办我见过很多新手写的重启脚本是这样子的os.system(taskkill /f /im xxx.exe) os.system(start xxx.exe)这个写法能跑但纯属能用就行完全没考虑到异常情况。如果第一行的taskkill失败了进程不存在、权限不足脚本照样会执行第二行启动命令结果就是——你以为重启了其实旧的还没死透新的又拉起来了两个进程抢同一个端口服务照样不可用而且更混乱了。做运维的人都知道这种假重启比不重启还可怕日志里完全看不出问题但请求就是时不时失败。2.2 健康检查重启不算完要确认它真的起来了这是暴力重启脚本里最容易被忽略的一环。很多人杀完进程、启动新进程之后就觉得完事了。可实际上服务进程在不等于服务可用。我习惯的三层健康检查策略是这样的进程存在检查进程还在列表里。端口监听检查端口处于LISTEN状态。业务健康检查请求某个health接口拿到预期响应码。一个服务重启后如果一分钟后端口还没监听那基本上可以认为是启动失败了——要么依赖的外部资源没就绪要么配置有问题要么代码里直接抛异常退出了。这时候脚本应该明显失败并把启动日志打出来方便定位问题。我见过太多人写的重启脚本只做前半段不做健康检查结果服务启动失败之后没有任何反馈等用户来投诉才发现问题。这事我确实有发言权——脚本不检查的话等于你自己打个盹的功夫服务可能已经挂了一个多小时了。2.3 日志记录给未来的自己留后路暴力重启这种操作本身是有破坏性的。所以日志一定要留。我一般在脚本里记录这几个信息操作时间被杀的进程PID杀进程的方式TERM还是KILL启动命令新进程的PID健康检查的结果别嫌这些信息啰嗦——真出问题的时候这份日志就是你排查问题的第一手线索。比如你发现某个服务每天凌晨三点被重启一次但是你的定时任务里根本没有这一条那就要考虑是不是有其他人或者其他的自动回复机制也在跑同样的脚本了。3. 实操过程与核心代码实现3.1 基础版按进程名重启先上一个最基础、但能直接用的版本。这个脚本用Python标准库实现没有任何第三方依赖Windows和Linux都能跑区别只在于杀进程的命令不一样。import os import sys import time import subprocess import signal import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[logging.StreamHandler()] ) logger logging.getLogger(restart-tool) def find_pids_by_name(process_name): 按进程名查找PID跨平台实现 pids [] if sys.platform.startswith(win): result subprocess.run( [wmic, process, where, fname{process_name}, get, processid], capture_outputTrue, textTrue ) for line in result.stdout.splitlines()[1:]: line line.strip() if line.isdigit(): pids.append(int(line)) else: result subprocess.run( [pgrep, -f, process_name], capture_outputTrue, textTrue ) for line in result.stdout.splitlines(): line line.strip() if line.isdigit(): pids.append(int(line)) return pids def kill_pid(pid, forceFalse): 先优雅退出再考虑强杀 if sys.platform.startswith(win): if force: subprocess.run([taskkill, /f, /pid, str(pid)], checkFalse) else: subprocess.run([taskkill, /pid, str(pid)], checkFalse) else: sig signal.SIGKILL if force else signal.SIGTERM try: os.kill(pid, sig) except ProcessLookupError: logger.warning(fPID {pid} 不存在可能已经退出) except PermissionError: logger.error(fPID {pid} 无权限操作需要提权) raise def restart_by_name(process_name, start_command, timeout10): 按进程名暴力重启 pids find_pids_by_name(process_name) if not pids: logger.warning(f未找到进程 {process_name}直接启动) for pid in pids: logger.info(f尝试优雅关闭 PID{pid}) kill_pid(pid, forceFalse) # 等待进程退出 waited 0 while waited timeout: pids find_pids_by_name(process_name) if not pids: break time.sleep(1) waited 1 if pids: logger.warning(f进程在 {timeout}s 内未退出强制杀死) for pid in pids: kill_pid(pid, forceTrue) time.sleep(1) # 启动新进程 logger.info(f启动命令: {start_command}) subprocess.Popen(start_command, shellTrue, stdoutsys.stdout, stderrsys.stderr) logger.info(重启流程执行完毕)这个脚本的核心思路就是先找到所有匹配的进程号然后逐个尝试优雅关闭如果超时未退出再强杀最后启动新进程。3.2 进阶级PID文件 端口监听检测基础版有个问题——万一名字匹配错了很容易误杀无辜进程。所以我后来在生产环境里用的方案改成了PID文件 端口检测双保险。PID文件思路很简单服务启动时把自己的PID写到一个固定路径重启脚本只杀PID文件里记录的那个进程这是粒度最准的方式。端口的检测是为了确认服务真的起来了。我用socket模拟一个TCP探测能连上端口说明进程至少起来了连不上说明大概率还没就绪。import socket def check_port_listening(port, host127.0.0.1, timeout3): 检查端口是否处于接受连接状态 sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: result sock.connect_ex((host, port)) return result 0 finally: sock.close() def wait_for_port(port, timeout30, interval2): 轮询等待端口就绪 waited 0 while waited timeout: if check_port_listening(port): logger.info(f端口 {port} 已就绪) return True time.sleep(interval) waited interval logger.info(f等待端口 {port} 就绪... {waited}s/{timeout}s) logger.error(f端口 {port} 在 {timeout}s 内未就绪) return False这里要特别说一下超时时间的选择。很多人喜欢把timeout设为5秒觉得快就是好。但服务真正起来需要多久取决于很多因素——类加载、数据库连接池预热、磁盘IO我见过一个Java服务光启动就要40秒的。如果把超时设得太短脚本会误判启动失败进而做出一系列错误处理操作反而把本来正在正常启动的服务给杀掉了。我自己一般会给足30秒到60秒的容忍时间宁可多等一会儿也不要误判。3.3 最终方案完整的生产级重启脚本把上面这些拼起来加上命令行参数解析和日志输出就是一个可以放到生产环境的版本了。我平时用的脚本框架大概长这样import os import sys import time import socket import logging import signal import subprocess import argparse from pathlib import Path logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.StreamHandler(), logging.FileHandler(restart.log, encodingutf-8) ] ) logger logging.getLogger(autorestart) def read_pid_file(pid_file): 读取PID文件返回PID列表 pid_path Path(pid_file) if not pid_path.exists(): logger.warning(fPID文件 {pid_file} 不存在) return [] pids [] for line in pid_path.read_text().splitlines(): line line.strip() if line.isdigit(): pids.append(int(line)) else: logger.warning(fPID文件中存在非法行: {line}) return pids def process_exists(pid): 检查进程是否存在跨平台 if sys.platform.startswith(win): result subprocess.run( [tasklist, /fi, fPID eq {pid}], capture_outputTrue, textTrue ) return str(pid) in result.stdout else: try: os.kill(pid, 0) return True except ProcessLookupError: return False except PermissionError: return True # 进程存在但无权限按存在处理 def kill_pid(pid, forceFalse): 关闭进程返回是否成功 if not process_exists(pid): logger.info(fPID {pid} 已经不存在无需操作) return True logger.info(f向 PID{pid} 发送 {SIGKILL if force else SIGTERM}) if sys.platform.startswith(win): cmd [taskkill, /f if force else /pid, /pid, str(pid)] result subprocess.run(cmd, capture_outputTrue, textTrue) return result.returncode 0 else: sig signal.SIGKILL if force else signal.SIGTERM try: os.kill(pid, sig) return True except ProcessLookupError: return True except PermissionError: logger.error(f无权限关闭 PID {pid}) return False def wait_process_exit(pids, timeout15): 等待所有进程退出 deadline time.time() timeout while time.time() deadline: remaining [pid for pid in pids if process_exists(pid)] if not remaining: return True time.sleep(1) return False def start_service(start_command, log_fileNone): 启动服务返回Popen对象 logger.info(f执行启动命令: {start_command}) if log_file: with open(log_file, a) as f: proc subprocess.Popen( start_command, shellTrue, stdoutf, stderrsubprocess.STDOUT, start_new_sessionTrue ) else: proc subprocess.Popen( start_command, shellTrue, stdoutsubprocess.DEVNULL, stderrsubprocess.STDOUT, start_new_sessionTrue ) logger.info(f服务已启动新PID{proc.pid}) return proc def restart(pid_file, start_command, portNone, kill_timeout15, port_timeout60, log_fileNone): 完整重启流程 logger.info( 暴力重启开始 ) # 第一步读取PID并关闭进程 pids read_pid_file(pid_file) if pids: logger.info(f从PID文件读到进程: {pids}) for pid in pids: kill_pid(pid, forceFalse) if not wait_process_exit(pids, kill_timeout): logger.warning(f进程未在 {kill_timeout}s 内退出进行强杀) for pid in pids: kill_pid(pid, forceTrue) time.sleep(1) else: logger.warning(没有找到PID文件可能服务本来就没在运行) # 第二步清理可能残留的端口占用 if port: time.sleep(2) # 给系统一点时间释放端口 if check_port_listening(port): logger.warning(f端口 {port} 仍被占用等待释放...) release_waited 0 while release_waited 10 and check_port_listening(port): time.sleep(1) release_waited 1 # 第三步启动服务 start_service(start_command, log_file) # 第四步健康检查 if port: if wait_for_port(port, port_timeout): logger.info(f健康检查通过端口 {port} 可访问) logger.info( 重启完成 ) return 0 else: logger.error(服务启动失败端口未就绪) logger.info( 重启失败 ) return 1 else: logger.info(未配置端口检查重启流程结束) logger.info( 重启完成 ) return 0 def main(): parser argparse.ArgumentParser(description服务暴力重启工具) parser.add_argument(--pid-file, requiredTrue, helpPID文件路径) parser.add_argument(--start-command, requiredTrue, help启动服务的命令) parser.add_argument(--port, typeint, defaultNone, help健康检查端口) parser.add_argument(--kill-timeout, typeint, default15, help等待优雅退出时间) parser.add_argument(--port-timeout, typeint, default60, help等待端口就绪时间) parser.add_argument(--log-file, defaultNone, help服务日志文件) args parser.parse_args() exit_code restart( pid_fileargs.pid_file, start_commandargs.start_command, portargs.port, kill_timeoutargs.kill_timeout, port_timeoutargs.port_timeout, log_fileargs.log_file ) sys.exit(exit_code) if __name__ __main__: main()这套脚本加上命令行参数之后用法就很清晰了python restart.py --pid-file /run/myapp.pid --start-command python app.py --port 8080跑起来之后脚本会自动完成杀进程、等待退出、强杀、重启、健康检查这一整套流程。退出码0表示成功1表示失败方便再接上定时任务做自愈。4. 常见问题与排查技巧实录4.1 杀不掉的进程怎么处理这是暴力重启里最经典的拦路虎。你明明执行了kill命令进程还在那里杵着甚至更离谱的是——你kill之后进程立刻以新的PID出现了。遇到前一种情况基本逃不出这几个原因权限不足进程是root启动的你的Python脚本是普通用户跑的无权向它发信号。解决方法是脚本以root权限执行或者在配置中单独指定需要提权的命令。Linux下还可以用polkit或者sudoers做精细化授权。不可中断的睡眠状态进程卡在内核态IO上比如等NFS响应、等设备驱动这种状态连SIGKILL都杀不掉。拿这种进程基本没有太好的办法只能等我通常的做法是给它标记异常状态并通知人工介入。僵尸进程进程已经死了但父进程没有调用wait回收。这种你杀不掉也不用杀直接跳过即可它不会占用端口和CPU但要留意它的存在会干扰进程匹配逻辑。后一种情况更麻烦——杀掉之后立刻有新进程冒出来这是我非常想强调的一定有守护进程在拉起它。常见的有systemd服务、supervisor进程管理器、Docker的restart策略这些守护机制会在服务退出后自动重新拉起一个实例。遇到这种情况光杀进程是没有用的你需要先停掉守护服务本身再做重启操作。比如基于systemd运行的服务正确的操作顺序是systemctl stop my.service停止守护服务然后再做你的清理和排查最后用systemctl start my.service把服务拉起来如果在写Python脚本时不处理这种守护场景你写再多的kill逻辑也没意义。4.2 端口占用和重启不完全的问题杀完进程之后经常遇到端口还在监听的情况。这是因为进程退出后TCP连接还要经过TIME_WAIT状态才能完全释放短则几十秒长则好几分钟。解决方案有两种一种是启用SO_REUSEADDR这个在监听的socket上设置即可从源头上解决TIME_WAIT问题但不适合你没法改代码的服务。另一种是等端口彻底释放。我在上面的脚本里加了最多10秒的等待逻辑实测大部分场景下足够了。如果等了10秒还是被占用就得考虑是不是有残留进程没杀干净需要重新走一遍进程查找逻辑。4.3 实战心得与避坑清单这里分享几个我自己踩过坑后总结出来的经验。第一重启脚本不要直接放到crontab里就跑。建议先加一个开关文件lock file防止上一次重启还没有结束另一次重启又开始了。两个重启流程同时跑鬼知道会发生什么。第二日志里一定要带PID。同一个服务名下的进程可能不只一个如果没有区分哪一个PID健康检查失败后续排查日志会非常痛苦。第三把start_command抽象成配置文件不要把命令写死在代码里。我见过有人在重启脚本里直接写了拼接命令结果后来服务启动参数变了脚本就废了。第四记得清理旧的PID文件。有些服务退出时不清理PID文件导致PID文件里存的其实是已经不存在的老进程号脚本每次都白跑一遍杀进程阶段。我在上面脚本里通过读PID文件前先检查进程是否存在来规避这个问题但最好还是从服务端根源上解决。第五超时时间的设置宁长勿短。就算是暴力重启也不要卡着极限时间做判断。一个服务在慢磁盘环境下启动花了35秒你把端口等待设成30秒那这个脚本就会永远报失败。最后我用这套思路处理过的典型案例包括本地产物型的爬虫脚本、数据处理任务、还有一些常驻内存的中间件服务。这个方案的真正价值不在于多高深——实际上它用的全是操作系统和Python标准库里的基础工具——而在于把从出问题到恢复之间的人肉时间压缩到几乎为零。以前靠手动处理一个服务假死至少折腾五分钟现在脚本跑完只需要一分钟不到这就是自动化的意义。如果你手头正好有这种反复假死的服务照着上面的脚本改改参数应该很快就能用起来。