Python长任务托管:screen会话保持与断点日志实践 1. 长跑型脚本为什么值得单独写一篇日志1.1 一次跑丢的三小时这篇是我这个学习日志系列的第 10 篇。前面九篇基本都在折腾 Python 本身——语法、数据结构、异常处理、几个常用库的用法。到第十篇我换了个角度不聊代码写得好不好聊代码写完之后怎么让它安安心心跑完。起因是上周的一次事故。我在一台开发机上跑一个数据清洗脚本预估跑三小时用的 Python逻辑不复杂就是逐行读取一批文本文件、清洗字段、写回结果目录。我想着反正时间不长就直接 SSH 上去python3 clean.py一把梭然后合上笔记本去吃午饭。回来一看SSH 断了——中间网络切换过一次。进程当然也跟着没了日志停在第四万多行输出目录里一半是截断的临时文件。最要命的是脚本里没做断点记录只能从头再跑。三小时白烧。那天下午我花了点时间研究怎么让这类任务“脱离终端连接独立存在”最后落到了screen上。所以这篇日志的主线就是Python 长任务 screen 会话托管。顺带说一句我那个项目目录下的脚本习惯用前缀命名这次的入口文件图省事写成了scre_python.py所以标题里就出现了“python和scre_python”这种看着有点怪的组合日志标题也没改。这篇写给两类人看手上已经有能跑通的 Python 脚本、但还没系统解决“后台持续运行”这件事的同学以及用着nohup但总觉得哪里不对、想换个方案的朋友。1.2 screen 补的是哪块拼图要讲清楚 screen 的价值得先讲清楚进程为什么会死。你在终端里敲python3 run.py这个 Python 进程的父进程是当前 shellshell 的父进程是 SSH 连接建立的会话。当你合上笔记本、网络抖动、或者单纯手滑关掉终端窗口SSH 连接断开系统会给这个会话里的进程发一个SIGHUP信号hang up挂断。默认情况下收到这个信号的进程直接终止。那 screen 做了什么它启动后自己成为一个常驻的会话管理器你所有的命令都在它下面跑。你的 SSH 连接只是接在这个管理器上的一个“显示器接口”。显示器拔掉了管理器还在里面跑的 Python 脚本当然也在。下次你连上去把显示器插回去看到的还是原来那个画面输出一行都没少。用生活里的东西打个比方你家的机顶盒和电视是两个东西。你看电视的时候把电视关了机顶盒还在录像。screen 就是那个机顶盒SSH 终端只是那块电视。这个心智模型很重要后面所有的命令、参数、排错思路本质都是围绕“管理机顶盒”和“接电视”这两件事展开的。理解了这一层screen -r和screen -d -r的区别就是“直接接上”和“先拔掉别人的线再接上”。2. screen 的三个核心概念一次讲透2.1 会话、窗口、分离screen 的概念体系其实很小就三层但新手容易被中文翻译绕晕我用大白话拆一下。会话session是最外层的容器一个会话有唯一的名字或者系统分配的 PID。你可以理解成一个“工作台”。一个工作台上可以干一件事也可以同时干好几件事。窗口window是会话内部的分屏单元。一个会话里可以开多个窗口每个窗口里跑一个独立的 shell彼此互不干扰。这一点很关键很多人以为要跑三个任务就得开三个 screen 会话其实一个会话开三个窗口就够了管理成本低得多。我在做批量采集的时候一个会话里常年挂着四个窗口主任务、日志 tail、交互调试、临时命令切来切去比开四个 SSH 窗口清爽得多。分离detach是 screen 最核心的动作。分离的意思是“把显示器拔掉机顶盒继续录像”。快捷键是CtrlA然后按D。注意这是两个连续的动作先按CtrlA松开再按D。CtrlA是 screen 的“命令前缀”所有 screen 自身的操作都要先按它这个前缀可以通过配置文件改成别的但我建议别改CtrlA在终端里很少被占用够用。对应的动作是回连attach用screen -r 会话名。这三个词记住了screen 就掌握七成了。剩下三成是排错和各种边角情况那部分放在第 5 节讲。2.2 同场竞技nohup、tmux、systemd 怎么选既然目标是让脚本持续跑市面上不止 screen 一个方案。我这几天把几个常见做法都试了一遍整理成下面这张表选型的时候对着看就行。方案启动方式断线后能否回看输出多任务管理开机自启学习成本适合场景直接python3 x.py 加个丢后台不能输出混在终端无不支持极低几秒就结束的临时命令nohup python3 x.py log 21 一行命令只能读日志文件无要自己管 PID不支持低一次性、不需要交互的长任务screen会话内启动能原始输出完整保留强会话来去自由需配合其他手段中需要交互、需要盯输出、要跑多个任务tmux会话内启动能分屏更强很强功能更丰富需配合其他手段中偏高追求分屏和现代配置的团队systemd service注册服务只能用 journalctl 查强但配置重原生支持高生产环境、需要崩溃自拉起这张表里最有信息量的是“断线后能否回看输出”这一列。nohup的那套写法看着简单但你只能事后去翻日志文件没法看到“实时滚动的原始状态”。而调试阶段恰恰最需要看实时输出——比如脚本卡在第几个请求、有没有一直重试同一个失败项、进度条走得快不快。这几个信息在 screen 里就是“回连看一眼”的事在 nohup 里就得tail -f一个日志文件而且要提前把 stdout 和 stderr 都重定向进去少重定向一个流就可能丢掉关键报错。我最后的选择是调试和手动编排阶段用 screen跑稳定之后的固定周期任务才考虑写成 systemd 服务。这个分界线不是绝对的但大体上可以这么切过程需要人盯的用 screen过程完全不需要人、只需要按时启动的用 systemd。至于 tmux功能比 screen 强垂直分屏、更好的复制粘贴、更现代的配置语法如果你的机器上本来就装了 tmux直接用 tmux 也完全没问题本文讲的会话/窗口/分离这套模型是共通的。3. 环境准备与最小配置3.1 安装与确认大部分 Linux 发行版不自带 screen先确认一下。查版本这一条命令信息量很大screen --version如果输出类似Screen version 4.09.00 (GNU) 30-Jan-22说明装好了。如果提示command not found用系统包管理器装上就行# Debian / Ubuntu 系 apt-get update apt-get install -y screen # RHEL / CentOS 系 yum install -y screen # 或者新版用 dnf install -y screen装完之后有一个容易忽略的点确认你的 shell 里 screen 的版本和默认编码行为。老版本 screen 在多字节字符也就是中文显示上偶尔会出问题表现是日志里的中文变成一堆问号或者方块。这不是 Python 的问题也不是日志编码的问题是终端复用器那层的字符宽度计算没有正确识别。排查方法很简单先跑一个最小验证screen -S encoding-test echo 中文测试 测试中文如果中文正常显示说明没问题。如果显示异常在两个地方动手一是在.screenrc里加utf8 on二是确认环境变量LANG是不是 UTF-8 的echo $LANG应该输出类似en_US.UTF-8或zh_CN.UTF-8。这两件事都做了还不行那多半是 SSH 客户端那头的字符集设置跟 screen 没关系了。3.2 一份只有五行但很值的 .screenrcscreen 默认的配置有几个地方挺反人类启动时弹一大堆版权信息、屏幕不能往回滚、底部没有状态栏导致你根本不知道自己现在在第几个窗口。花两分钟在用户主目录下建一个~/.screenrc体验立刻不同。我用的是下面这个极简版本每行都有明确目的# 关掉启动时的版权提示省得好几屏信息把真正的输出顶走 startup_message off # 把回滚缓冲提到 10000 行排查问题基本够用 defscrollback 10000 # 关掉那声很吵的蜂鸣提示 vbell off # 底部常驻状态栏显示当前所有窗口 hardstatus alwayslastline hardstatus string %{ kG}%-Lw%{ kW}%50%n %t%{-}%Lw%第三行的defscrollback 10000值得多说一句。screen 默认的回滚缓冲是 100 行也就是说你只能往上看 100 行历史输出。跑一个 Python 脚本报错栈动辄几十行加上进度日志100 行往往刚好把关键信息顶出去。调到 10000 之后回看历史的方式是CtrlA然后按[进入copy mode复制模式这时候可以用方向键或者 PageUp/PageDown 翻页按Esc退出。第五行的hardstatus是状态栏格式串看着像天书拆开看其实简单%-Lw是左侧窗口列表%{ kG}和%{ kW}是配色切换%n是窗口编号%t是窗口标题%50表示超出 50 个字符时折叠。你不需要记住每个占位符复制过去能用就行想改配色再查手册。配好之后再起一个会话就能看到底部有一行反色的状态栏左边列出所有窗口编号和名字当前窗口高亮。这个小东西的价值在于你同时跑三四个任务的时候瞥一眼就知道自己在哪不用敲命令去查。用.screenrc统一配置还有个额外好处——它对新开的每个会话都生效不用每次都调。4. 实战把一个采集脚本托管进 screen4.1 脚本侧的三处改造讲操作流程之前必须先讲脚本本身的改造。这是我踩坑之后最深的体会screen 只能保证进程不被挂断它保证不了你的任务可以在崩溃或中断后接着跑。屏幕拔了机顶盒还在录但机顶盒自己死机了录像照样断。所以脚本里至少要补三件事日志落盘、异常兜底、断点续跑。下面这段是我最终用的版本做了简化但保留了所有关键结构可以直接拿去改import argparse import json import logging import os import random import signal import sys import time BASE_DIR os.path.dirname(os.path.abspath(__file__)) LOG_DIR os.path.join(BASE_DIR, logs) os.makedirs(LOG_DIR, exist_okTrue) # 关键点一日志同时写文件和控制台 logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(os.path.join(LOG_DIR, task.log), encodingutf-8), logging.StreamHandler(sys.stdout), ], ) log logging.getLogger(collector) # 关键点二捕获终止信号做完当前这一条再退 STOP False def _graceful(signum, frame): global STOP STOP True log.warning(收到信号 %s本轮结束后保存断点退出, signum) signal.signal(signal.SIGTERM, _graceful) signal.signal(signal.SIGINT, _graceful) def load_progress(path): if os.path.exists(path): with open(path, encodingutf-8) as f: return json.load(f) return {done: []} def save_progress(path, progress): # 先写临时文件再原子替换防止写一半被中断导致断点文件损坏 tmp path .tmp with open(tmp, w, encodingutf-8) as f: json.dump(progress, f, ensure_asciiFalse) os.replace(tmp, path) def fetch(item_id, retry3): # 关键点三单条失败重试指数退避不拖垮整批 for i in range(retry): try: time.sleep(0.05) if random.random() 0.15: raise RuntimeError(模拟一次偶发失败) return {id: item_id, ok: True} except Exception as e: log.warning(第 %s 次失败 item%s err%s, i 1, item_id, e) time.sleep(2 ** i) return {id: item_id, ok: False} def main(): ap argparse.ArgumentParser() ap.add_argument(--start, typeint, default1) ap.add_argument(--end, typeint, default100) ap.add_argument(--progress, defaultos.path.join(LOG_DIR, progress.json)) args ap.parse_args() progress load_progress(args.progress) done set(progress[done]) log.info(区间 %s-%s历史已完成 %s 条, args.start, args.end, len(done)) for i in range(args.start, args.end 1): if STOP: log.info(收到停止指令保存断点后退出) break if i in done: continue res fetch(i) if res[ok]: done.add(i) progress[done] sorted(done) if len(done) % 10 0: save_progress(args.progress, progress) time.sleep(0.2) save_progress(args.progress, progress) log.info(本次结束累计完成 %s 条, len(done)) if __name__ __main__: main()三处改造各自解决一个具体问题我分开说。日志双写只写文件的话你在 screen 里回连看到的是白屏什么都不知道只写控制台的话screen 会话被kill或者机器重启所有信息烟消云散。两个 handler 同时挂上成本一行代码收益是任何时候都有两套记录。注意encodingutf-8这个参数一定要写不加的话在某些环境下默认编码不是 UTF-8中文日志会写成一堆乱码事后完全没法看。信号处理这一条是很多人会漏的。你按CtrlC或者用kill发信号的时候进程默认行为是立刻死。如果它正好在写断点文件、或者正好在处理一条请求的中间状态就可能留下脏数据。挂上SIGTERM和SIGINT的处理器把“立刻死”变成“做完手头这一条再死”脏数据的概率就降下来了。断点文件的原子写os.replace在同一个文件系统内是原子操作。如果直接open(path, w)然后写写到一半进程被杀文件就废了下次启动json.load直接抛异常。先写.tmp再替换最坏情况也只是丢掉最后一次保存但不会读到坏文件。这个细节在数据库操作里是常识在脚本里却经常被忽略。4.2 完整操作流水一条一条来脚本准备好了接下来是 screen 的操作流程。我把顺序和每一步的用意都列清楚。第一步建会话。会话名起得有辨识度一点别用默认的数字 PID不然跑起来四五个会话你根本分不清谁是谁screen -S collect敲完这一句屏幕上会短暂闪一下然后回到提示符看着像什么都没发生。其实你已经在 screen 会话内部了。验证方式是按CtrlA再按D如果能看到类似[detached from 12345.collect]的提示说明会话确实建起来了。第二步进项目目录起任务。这里有个小技巧命令后面接21 | tee -a logs/console.log。虽然脚本内部已经双写日志了但加上这一层的好处是能把 Python 解释器自己吐出来的东西比如未捕获异常的 traceback、SyntaxError这种在 logging 配置生效之前就产生的报错也一并捕获到文件里cd /path/to/project python3 run.py --start 1 --end 5000 21 | tee -a logs/console.log第三步分离。CtrlA松开再按D。屏幕上会打印一行[detached from ...]然后你回到原来的 shell。这时候关掉 SSH、合上笔记本、断网都不影响任务继续跑。第四步回来看。重新连上机器先列一下有哪些会话screen -ls输出大概长这样There are screens on: 12345.collect (Detached) 12388.debug (Attached) 2 Sockets in /run/screen/S-root.Detached表示没人连着直接screen -r 12345或者screen -r collect就能接上——用名字前缀匹配就行不必敲完整 PID。Attached表示还连着这时候直接-r会报There is no screen to be resumed得先把它从原来那个连接上踢下来screen -d -r collect-d是 detach 别人-r是 resume 自己合起来就是“抢过来”。这个组合我在笔记本和台式机之间切换的时候用得最多远程那边可能还挂着昨晚的悬挂连接其实早就断了但 screen 不知道状态还标着 Attached-d -r一劳永逸。第五步任务跑完退出会话。在会话内部敲exit或者按CtrlD窗口关掉所有窗口都关掉之后整个会话就结束了。如果会话已经Dead掉了比如被kill过用下面这条清掉残留记录不然screen -ls会一直挂着幽灵条目screen -wipe还有一个特别实用的非交互式启动方式适合写进脚本或者定时任务里screen -d -m -S collect bash -lc cd /path/to/project python3 run.py --start 1 --end 5000-d -m的组合意思是“建一个会话但立刻分离”全程不需要人工按CtrlA D。这条命令我放在一个start_job.sh里一键起任务比手动敲快得多。注意这里用了bash -lc-l是登录 shell会把/etc/profile和用户的 profile 都加载一遍。这个细节很关键第 5 节会专门讲为什么。4.3 多窗口与回看一个会话里管多个任务比开多个会话省心。常用操作就这几个# 在会话内部新建一个窗口 CtrlA c # 切换到下一个 / 上一个窗口 CtrlA n CtrlA p # 列出所有窗口用方向键选 CtrlA # 给当前窗口改个有意义的名字 CtrlA A # 进入回滚模式翻看历史输出 CtrlA [ # 在回滚模式里Esc 退出还有一种从外部往已有会话里加窗口的写法不需要先 attach 进去screen -S collect -X screen -t job2 bash -lc cd /path/to/project python3 run.py --start 5001 --end 10000-X是“给已存在的会话发一条命令”screen这个命令本身表示“新开一个窗口”-t job2给窗口命名。这一招的好处是可以写进批处理脚本一个会话里挂一排任务窗口然后退出来让它们并行跑。注意多个窗口并行跑的时候务必确认这些任务之间不共享会冲突的资源。最常见的冲突是同一个断点文件、同一个输出文件名、同一条数据库连接池。我吃过一次亏——两个窗口读同一个progress.json互相覆盖最后断点记录完全乱套只能全量重跑。并发之前先把输出路径按任务 ID 分开能省掉大量事后排查。5. 故障复盘与速查表5.1 四个真实踩过的坑第一个坑会话在但接不上。报错There is no screen to be resumed matching collect。这时候别急着重启先screen -ls看一眼。如果条目标着Dead说明会话里的进程异常终止了用screen -wipe清理。如果条目正常但就是接不上多半是状态残留试试screen -dr collect-d -r连写还不行就kill -9掉会话对应的进程重开一个。第二个坑交互式跑没问题放进 screen 就报找不到模块。这个坑我卡了小半天。原因是 screen 启动时的环境变量跟你登录时的环境变量不一致。特别是用screen -d -m从脚本或定时任务里拉起的时候走的不是登录 shellPATH 里没有你平时安装 Python 包的那个路径import直接失败。解决办法有两条任选一是启动命令加bash -lc ...强制走登录 shell二是脚本里用绝对路径调用解释器比如/usr/local/bin/python3别指望python3这个名字一定被解析到正确的那个。第三个坑中文日志变成问号。前面 3.1 提过一次这里补一个更隐蔽的情况日志文件本身是好的你cat出来是正常中文但在 screen 里tail -f显示成方块。这说明问题在显示层不在存储层。检查顺序是locale命令确认LANG和LC_ALL是 UTF-8.screenrc里加utf8 onSSH 客户端也要设成 UTF-8。三层都对上才能正常显示少一层都会出问题。第四个坑磁盘被日志撑爆任务和机器一起卡死。这个最危险。一个每秒写几十行的进度日志跑一晚上轻松几个 G。磁盘写满之后不只是任务失败机器上其他服务也会跟着挂。教训是长跑任务启动前先把日志落盘策略定下来 6.2 节 会讲具体做法。最起码的一条进度类日志降级成INFO里带计数别每处理一条就log.info(处理了 %s % i)改成每 100 条打一次日志量能直接降两个数量级。5.2 排查速查表把上面这些和我后来补的几种情况整理成表出问题的时候对着找现象最可能的原因处理动作no screen to be resumed会话名拼错、或状态残留screen -ls确认精确名称再用-dr会话一直显示Attached接不上旧连接未正常断开screen -d -r 名称强制接管screen -ls出现Dead条目会话内进程异常终止screen -wipe清理检查内核日志screen 里import报找不到模块非登录 shellPATH 不完整用bash -lc启动或写死解释器绝对路径中文显示为方块或问号编码链路某一层不是 UTF-8locale检查 .screenrc加utf8 on脚本被kill后断点文件损坏直接覆写中途被打断改原子写临时文件 os.replace磁盘占用暴涨日志无节制写入加切割策略进度日志降频按CtrlA没反应被别的软件抢占或时序不对先松开CtrlA再按功能键别连按提示遇到任何“任务莫名其妙没跑完”的情况第一动作永远是看日志文件的最后几行而不是重启。日志末尾通常能直接告诉你死在哪一步、抛了什么异常。直接重启等于把现场破坏了同样的坑下次还会踩。6. 进阶批量编排与日志治理6.1 一个会话里跑一批任务任务量上来之后一个个手动起就太慢了。我的做法是写一个 shell 脚本把大区间切成若干段每段塞进独立窗口并行跑。核心就三行#!/bin/bash set -e SESSIONbatch-run PY/usr/local/bin/python3 PROJ/path/to/project # 建会话并启动第一个窗口 screen -d -m -S $SESSION bash -lc cd $PROJ $PY run.py --start 1 --end 2000 # 依次追加后续窗口每个窗口跑一段区间 screen -S $SESSION -X screen -t seg2 bash -lc cd $PROJ $PY run.py --start 2001 --end 4000 screen -S $SESSION -X screen -t seg3 bash -lc cd $PROJ $PY run.py --start 4001 --end 6000 screen -S $SESSION -X screen -t seg4 bash -lc cd $PROJ $PY run.py --start 6001 --end 8000 echo 任务已全部投递用 screen -r $SESSION 查看写这种编排脚本有三个必须守住的规矩。区间必须互斥且不重叠。切分的时候用整数除法算边界别手写数字手写最容易在接缝处漏一条或者重复一条。像上面这种 2000 一段的切法段与段之间是 1-2000、2001-4000接缝清晰。每个段的输出路径必须分开。断点文件叫progress_seg1.json、progress_seg2.json输出目录也分开。共享同一个文件必然出问题这不是概率问题是时间问题。并行度别贪多。并行窗口数量取决于你的瓶颈在哪。如果是网络请求密集瓶颈在下游服务开四到六个通常合适如果是纯 CPU 计算并行数超过核心数就是互相拖累反而更慢。我一般先开两个测一下单段耗时再决定要不要加到四个。6.2 日志切割与磁盘保护长跑任务的日志治理我总结成三条按重要性排。第一条日志按天或者按大小切。最省事的办法是脚本内部判断文件大小超过阈值就换一个文件名import os from logging.handlers import RotatingFileHandler handler RotatingFileHandler( os.path.join(LOG_DIR, task.log), maxBytes20 * 1024 * 1024, # 单文件 20MB backupCount5, # 最多保留 5 个历史文件 encodingutf-8, )RotatingFileHandler是标准库自带的maxBytes加backupCount两个参数就把总磁盘占用锁在 100MB 左右不会无限增长。backupCount别设太大5 个够事后排查了设成 50 只是把爆盘时间往后推。第二条进度类日志降频。这条比第一条更有效。处理十万条数据如果每条都打一行日志光日志 IO 就是很大的开销还会把磁盘写满。正确做法是按批次打每 100 条或者每 10 秒打一次累计进度。关心的信息其实就三个总数、已完成、失败数这三样每 10 秒更新一次完全够用。第三条加一道磁盘水位保护。在循环里顺手检查一下剩余空间低于阈值就主动停下来保存断点。shutil.disk_usage一行就能拿到import shutil def disk_ok(path, min_free_gb2): usage shutil.disk_usage(path) free_gb usage.free / (1024 ** 3) if free_gb min_free_gb: log.error(剩余空间仅 %.2f GB主动停止, free_gb) return False return True主动停比被动崩好太多。被动崩的时候你可能正在写断点文件结果写了一半没空间了文件损坏下次启动直接报错。主动停是优雅退出断点文件是完整的补上空间接着跑就行。这一条我在两个项目里都加上了之后再没出现过因为磁盘写满导致断点报废的情况。7. 一点个人体会这个系列写到第十篇我最大的感受是Python 学到最后真正的门槛不在语法在于“怎么让它可靠地把活干完”。语法查手册十分钟能会但一个任务跑三个小时不出岔子需要的是日志、信号、断点、磁盘、会话托管这一整套外围功夫。screen 只是其中最便宜的一环——几行配置加三个快捷键就能把“断线即失败”这个最大的不确定性消掉。我现在给任何预计超过十分钟的脚本都套同一套模板日志双写、信号捕获、原子写断点、起在命名会话里。这套模板搭一次后面所有任务复用边际成本接近于零。如果你手上正有脚本在裸奔建议就从加一个screen -S 名字开始先解决断线问题再逐步补日志和断点。顺序别反了先能跑住再谈跑好。