Python后端部署排障03:关掉终端以后,谁还在照看 Python 进程
你在 SSH 终端启动服务,看见启动日志,接口也能访问。第二天登录,服务消失了。另一种情况恰好相反:终端已经关闭,旧进程仍占着端口,新版本怎么启动都失败。这两种现象都说明,我们需要把“执行了一条命令”升级为“管理一个有身份、有状态、有结束流程的进程”。
本课先做一个很小的 worker。它只维护心跳和 PID 文件,收到停止信号时写下清理记录。因为没有数据库和网络请求干扰,我们可以专心观察谁启动了它、谁通知它退出、它有没有完成finally。再交给 systemd user 管理,看看终端、进程和服务管理器的责任如何分开。
E02 完整答案:权限失败要真实发生
运行python3 lessons/03/answer_previous.py,前置是 Linux 普通 UID。它与上课实验一致,完整保留“失败—修改—读回—清理”四段。若使用 root,程序明确退出,而不是打印一个假通过;这也是后续验收脚本应遵守的原则,前置条件不满足就报告失败原因。
lessons/03/answer_previous.py
"""Linux普通用户实验:目录缺x造成失败;只补所有者x后恢复。"""importjsonimportosfrompathlibimportPathimportstatimportsysimporttempfiledefmain():ifsys.platform!="linux"oros.geteuid()==0:raiseSystemExit("Run on Linux as a non-root UID; root bypasses this experiment")withtempfile.TemporaryDirectory(prefix="quoteops-permission-")asname:directory=Path(name)/"private"directory.mkdir(mode=0o700)target=directory/"evidence.txt"before=0o600# 有目录r/w,故意没有遍历所需x。directory.chmod(before)try:try:target.write_text("should fail\n",encoding="utf-8")exceptPermissionErrorasexc:failure={"type":type(exc).__name__,"errno":exc.errno}else:raiseAssertionError("Expected PermissionError, got successful write")directory.chmod(before|stat.S_IXUSR)target.write_text("recovered\n",encoding="utf-8")asserttarget.read_text(encoding="utf-8")=="recovered\n"after=stat.S_IMODE(directory.stat().st_mode)assertafter==0o700print(json.dumps({"uid":os.geteuid(),"failure":failure,"before":oct(before),"after":oct(after),"readback":"recovered"},indent=2))finally:directory.chmod(0o700)# 即使断言失败,也让TemporaryDirectory能清理。if__name__=="__main__":main()注意补权限的位置。我们只修改自己创建的内部目录,模式从600到700,并没有扩大其他用户权限。finally恢复遍历能力后,临时目录管理器才有机会删除内容。这种清理意识马上会迁移到进程:如果启动了子进程,就要在成功与失败路径中都承担回收责任。
一个程序文件,可以有很多个进程
程序是磁盘上的代码,进程是正在运行的一次实例。连续运行同一个 Python 文件两次,会得到两个 PID、两份内存和各自的打开文件。PID 可能被系统复用,所以昨天记录的数字不能直接拿来杀今天的进程。需要同时观察启动时间、命令行、所属用户与父进程,确认它确实是本次任务创建的对象。
ps-eouser,pid,ppid,stat,etimes,args--sort=pidps-p$$-opid,ppid,stat,argsPPID把进程连成父子关系,STAT显示运行状态,etimes是启动以来的秒数。睡眠态不意味着程序挂了:等待请求、等待计时器的服务大多时间都在睡眠。僵尸进程则是子进程已经结束、父进程尚未读取退出状态留下的记录,继续给僵尸发停止信号没有意义,应该检查父进程为何不回收。
Linux 的/proc/<PID>/提供该进程的观察窗口,例如status、cmdline、fd/。读取这些信息可能受身份和系统策略限制,而且进程随时可能退出;看到“文件不存在”时先考虑观察对象已经结束,不要假定磁盘坏了。cmdline使用空字节分隔参数,直接当普通文本阅读有时显得奇怪,这是格式问题。
PID 一还承担特殊职责。容器里如果 shell 是 PID 一、Python 藏在它下面,信号转发和子进程回收就可能出问题。后续 Dockerfile 使用 exec 形式入口,让应用直接接到停止信号。不能仅凭“进程在容器里”推断它就会优雅停止;入口形式、信号处理和停止超时必须一起检查。
SIGTERM 是请求,SIGKILL 是强制终止
SIGTERM 允许程序安装处理逻辑,先停止接新任务、收尾当前任务、关闭资源再退出。SIGKILL 不能被捕获、忽略或转换成 Python 异常,内核直接结束进程,因此不能期待finally执行。把kill -9当作第一选择,等于主动丢弃应用正常收尾的机会。
但也不能反过来说“用了 SIGTERM 就保证所有数据安全”。程序可能没有处理它,可能卡在阻塞调用,可能清理时间超过管理器限额。我们的 worker 让信号处理函数只设置停止标记,主循环每零点一秒检查一次;写文件与打印放在普通执行路径中。这使停机步骤易于阅读,也避免在信号回调中堆积复杂操作。
labs/signal_worker.py
"""Linux信号worker:--directory必须是本次实验专用目录。"""importargparsefromdatetimeimportdatetime,timezoneimportosfrompathlibimportPathimportsignalimportsysimporttimedefmain():parser=argparse.ArgumentParser()parser.add_argument("--directory",required=True,type=Path)args=parser.parse_args()ifsys.platform!="linux":raiseSystemExit("This SIGTERM experiment requires Linux")directory=args.directory.resolve()directory.mkdir(parents=True,exist_ok=True)stop=Falsereceived=Nonedefrequest_stop(number,frame):nonlocalstop,received stop,received=True,number signal.signal(signal.SIGTERM,request_stop)signal.signal(signal.SIGINT,request_stop)pid_path=directory/"worker.pid"# 独占创建防止两个worker意外共用证据目录;陈旧目录需另行检查。withpid_path.open("x",encoding="utf-8")ashandle:handle.write(str(os.getpid()))try:print(f"worker started pid={os.getpid()}",flush=True)whilenotstop:(directory/"heartbeat.txt").write_text(datetime.now(timezone.utc).isoformat()+"\n",encoding="utf-8")time.sleep(0.1)finally:(directory/"shutdown.txt").write_text(f"pid={os.getpid()}signal={received}finally=yes\n",encoding="utf-8")pid_path.unlink(missing_ok=True)print(f"worker stopped signal={received}finally=yes",flush=True)if__name__=="__main__":main()worker.pid使用独占创建,目的是防止两个实例误用同一证据目录。它不是通用进程锁,更不能仅凭文件存在就断言进程活着;异常断电或 SIGKILL 可能留下陈旧文件。每次实验选择新的空目录,检查心跳后再发信号,最容易避免把上一次结果当成这一次证据。
停止时finally写shutdown.txt、删除自己的 PID 文件并输出日志。正常 SIGTERM 的记录包含signal=15 finally=yes。如果整个进程遭遇 SIGKILL,这段清理没有执行机会。这里保存心跳时间只是为了验证程序运行过,并不是生产级健康检查;磁盘可写也不说明某个业务依赖可用。
先手动观察,再交给服务管理器
下面是一段前台 shell 中可直接执行的实验。所有对象来自本次创建的临时目录与$!,不会通过模糊的进程名匹配去停止其他程序。循环等到心跳出现,避免刚启动就发送信号。实际自动验收会使用下一课的 Python 答案,带有超时和失败路径的子进程回收。
worker_dir=$(mktemp-d-tquoteops-worker-XXXXXX)python3 labs/signal_worker.py--directory"$worker_dir"&worker_pid=$!forattemptin$(seq150);dotest-f"$worker_dir/heartbeat.txt"&&breaksleep0.1doneps-p"$worker_pid"-opid,ppid,stat,argskill-TERM"$worker_pid"wait"$worker_pid"cat"$worker_dir/shutdown.txt"rm-r--"$worker_dir"wait的作用不仅是“等一下”,它读取本 shell 子进程的结束状态。只发送 TERM 就立刻宣告停止,存在窗口:应用可能仍在收尾。自动化应等到进程退出,再检查退出码和清理证据。手动命令中如果前半段报错,请先确认本次 PID 是否仍存在,再清理目录,不要继续照抄最后一行掩盖问题。
systemd user 管理个人实验,不需要系统级服务
systemd user 是当前用户的服务管理器,配置一般放在~/.config/systemd/user/。它与系统级/etc/systemd/system/属于不同范围。前置是 Linux 主机具有可用的用户会话管理器;若systemctl --user报无法连接用户总线,应记录环境条件,不能把同一命令去掉--user后直接作为替代,更不应为了课程随意修改宿主系统服务。
下面模板假定课程目录位于~/quoteops;若位置不同,应把ExecStart中脚本路径改为实际绝对路径,证据目录也应选本次专用位置。模板中的%h由 systemd 展开为用户家目录,ExecStart 不是普通 shell 命令,不会自动执行你的 shell 初始化文件,也不会自动使用刚激活的虚拟环境。
labs/quoteops-worker.service
[Unit] Description=QuoteOps personal signal experiment [Service] Type=simple ExecStart=/usr/bin/python3 %h/quoteops/labs/signal_worker.py --directory %h/.local/state/quoteops-worker KillSignal=SIGTERM TimeoutStopSec=5 Restart=no UMask=0077 NoNewPrivileges=true StandardOutput=journal StandardError=journal [Install] WantedBy=default.target将核对后的模板安装成一个自己专用的新单元名,例如quoteops-worker-lab.service。若该名字已经存在,先选另一个名字,不要覆盖既有服务。这里不需要enable;实验只启动一次,停止后删除本次创建的单元文件即可。daemon-reload让管理器重读配置,它不会自动重启所有服务。
mkdir-p~/.config/systemd/useriftest-e~/.config/systemd/user/quoteops-worker-lab.service;thenprintf'Unit already exists; choose a new name before continuing.\n'>&2elsecplabs/quoteops-worker.service ~/.config/systemd/user/quoteops-worker-lab.service systemctl--userdaemon-reload systemctl--userstart quoteops-worker-lab.service systemctl--userstatus quoteops-worker-lab.service --no-pager journalctl--user-uquoteops-worker-lab.service-n20--no-pager systemctl--userstop quoteops-worker-lab.servicecat~/.local/state/quoteops-worker/shutdown.txtfi示例用显式判断保护既有同名单元;如果任何管理命令失败,应停下来读取原因,不要继续把旧证据当成本次结果。TimeoutStopSec=5给正常结束一个有限窗口,超过窗口管理器可能强制结束。Restart=no让观察更单纯,避免程序失败后自动重启把第一份证据覆盖。stdout/stderr 进入 journal,能把启动、停止和具体时间放在一起看。
清理时先停止本次单元,确认不再运行,再删除自己复制的单元文件与本次证据目录,最后daemon-reload。不要批量删除用户的 systemd 配置。登出后用户管理器是否继续运行还受会话和 linger 设置影响,本课不修改它;长期运行的部署策略要由管理员明确决定,不能从一次 SSH 会话的实验推断。
服务管理器告诉你的,也要分层理解
服务处于运行状态,通常说明管理器认可它的启动过程并且相关进程仍存在,不意味着每个业务请求都正确。我们的小程序即使写出了心跳,也可能根本没有业务端口;报价服务即使进程存在,也可能尚未完成数据库迁移。因此排障时至少分开观察管理状态、进程退出状态、应用日志和业务探测。它们各自回答一个问题,不能互相代替。
日志中的最后一行也不一定是故障原因。有时程序先遇到写入异常,随后清理又失败,最终画面只剩第二个报错。保留完整时间段,寻找最早偏离预期的位置,比只截图最后几行更有用。我们的子进程答案会收集整个短实验输出,是因为它有严格上限;长时间运行的服务则需要按时间和单元筛选,避免无限读取日志。
自动重启带来另一个观察陷阱:当前看起来正常,不代表刚才没崩溃。服务可能在短时间里不断启动又退出,每一次都生成新的进程号。排查时应结合启动次数、退出原因和时间顺序,判断究竟是一次恢复还是持续失败循环。把重启间隔调得很短,只会让失败更密集,并不会修复丢失配置或目录权限。
本课将重启关闭,是为了让第一次失败保持可见。生产场景常需要重启策略,但仍必须给出重试间隔与次数边界,避免配置错误消耗大量资源。一个成熟的停止流程还需要考虑正在处理的请求、后台任务和外部连接;信号处理函数只是入口,真正的资源释放必须由应用自身实现。
你可以在练习记录里分别写下“收到停止请求”“完成清理记录”“父进程确认退出”三个时间点。它们可能非常接近,却不是同一个事件。掌握这条时间线以后,再讨论容器的停止宽限期与代理连接排空,就有了具体对象,不会只剩抽象的“优雅”二字。
E03:等到进程真的结束再验收
E03:由 Python 启动本课 worker,等待心跳,核对 PID 文件等于子进程 PID,再只给这个子进程发送 SIGTERM。五秒内等待结束,确认退出码为零、PID 文件消失、shutdown.txt含signal=15 finally=yes。任何步骤失败都必须回收自己创建的子进程并清理临时目录。下一课提供完整代码,也会用相同的受控思路观察文件描述符与磁盘空间。
课程目录:《Linux、网络与后端部署排障:从“接口能跑”走到“服务能维护”》 · 上一篇:第02篇 · 下一篇:第04篇