Windows 单文件 GUI 守护工具打包与稳定运行
把"一个 Python 服务 + 一个看门狗"做成一个双击即用的单文件窗口 exe,并做到开机自启、关窗不丢保活、多实例不互杀。本技能是 SCADA 监控管理器 MonitorManager.exe 实战沉淀,每段都来自真实踩坑,不要凭空发挥。
0. 设计与包装原则(先读)
- 纯标准库:GUI 用
tkinter,保活用threading+subprocess,配置读写用内置json/winreg。不引入第三方依赖 → 打包体积小、运行稳。 - 独立打包 venv:新建一个 venv 专门装
pyinstaller,不要污染被守护服务的运行环境。 - 职责单一:管理器只负责"启动/停止/看护"既有的服务脚本(如
alarm_monitor.py),升级服务代码无需改动管理器。 - 状态以真实进程为准,不要只靠内存布尔开关(这是"停止系统误报未运行"的根因,见步骤 7)。
1. 单文件窗口打包
# 在独立打包 venv 里
python -m venv C:\Users\yanya\.workbuddy\binaries\python\envs\builder
builder\Scripts\pip install pyinstaller
builder\Scripts\pyinstaller.exe --noconfirm --onefile --windowed --name MonitorManager monitor_manager.py
--windowed:不弹控制台黑框;--onefile:产出单个 exe。- ⚠️ PyInstaller 单文件运行时会有"引导外壳进程 + 真正应用进程"两个 exe 进程,应用进程持有单实例锁。任务管理器看到 2 个同名进程是正常的,不是 Bug。
2. 单实例互斥(防止重复拉起打架)
用"占用一个本地 TCP 端口"当互斥锁——简单可靠,比文件锁干净。
import socket
INSTANCE_PORT = 58099
def ensure_single_instance():
"""占用本地端口作为单实例锁;返回 False 表示已有实例在运行。"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
sock.bind(("127.0.0.1", INSTANCE_PORT))
sock.listen(1)
globals()["_instance_sock"] = sock # 挂全局防 GC 释放
return True
except OSError:
return False
# 在 main() 最前面:
def main():
if not ensure_single_instance():
# 开机自启(--minimized)场景:静默退出,避免弹窗干扰
if "--minimized" not in sys.argv:
try:
r = tk.Tk(); r.withdraw()
messagebox.showinfo("提示", "监测管理器已在运行,无需重复启动。")
r.destroy()
except Exception:
pass
return
...
- 重复双击 / 开机自启第二份会被拦截,不会并存多套保活。
3. 防多实例互杀(保活核心坑)
致命坑:保活逻辑里若每次都 taskkill /F /IM cloudflared.exe 先杀"全部"同类进程再重启,一旦有多个 Manager 实例并存,它们会互相杀对方的进程 → 隧道重启风暴。
正确做法:保活前先 detect_running() 看真实状态,只在"进程缺失 / 公网不可达且冷却已过"时才重启;绝不一上来就无脑 kill 全部。
import subprocess, time
def http_ok(url, timeout=8):
try:
r = subprocess.run(["powershell", "-NoProfile", "-Command",
"Invoke-WebRequest -Uri '%s' -UseBasicParsing -TimeoutSec %d" % (url, timeout)],
capture_output=True, creationflags=0x08000000, timeout=timeout+5)
return r.returncode == 0
except Exception:
return False
def detect_running():
"""以真实进程为准:(python_ok, tunnel_ok)。"""
try:
out = subprocess.check_output(
['wmic', 'process', 'where', "name='python.exe'", 'get', 'CommandLine', '/value'],
creationflags=0x08000000, timeout=15).decode('gbk', 'ignore')
py = 'alarm_monitor.py' in out
except Exception:
py = False
try:
t = subprocess.check_output(['tasklist', '/FI', "IMAGENAME eq cloudflared.exe"],
creationflags=0x08000000, timeout=15).decode('gbk', 'ignore')
cf = 'cloudflared.exe' in t
except Exception:
cf = False
return py, cf
# 保活线程里:
CF_RESTART_COOLDOWN = 30
def keepalive_loop(self):
while True:
time.sleep(20)
py_ok, cf_ok = detect_running()
public_ok = http_ok(self.cfg["PUBLIC_URL"])
# 隧道:进程在且公网通 -> 不动;否则才重启(且距上次启动 > 冷却)
if (not cf_ok) or (cf_ok and not public_ok and (time.time() - self.last_cf_start) > CF_RESTART_COOLDOWN):
self.restart_tunnel() # 只重启隧道,不杀 python 服务
- 配套技能
win-powershell-envfix处理restart_tunnel里Start-Process崩溃的坑(PATH 双键)。
4. 令牌 / 配置 BOM 处理(隐蔽认证坑)
真实事故:隧道令牌文件带 UTF-8 BOM(EF BB BF)。用 open(encoding='utf-8') 读取会把 3 个不可见字节混进令牌 → Cloudflare 报 Provided Tunnel token is not valid → 隧道启动即退出 → 公网 502/530 抖动。
修复:一律用 utf-8-sig 自动去 BOM。
# 读令牌 / 读配置文件,统一 utf-8-sig
with open(token_file, "r", encoding="utf-8-sig") as f:
tok = f.read().strip()
with open(config_file, "r", encoding="utf-8-sig") as f:
cfg = json.load(f)
- 判断文件是否带 BOM(PowerShell):
$b=(gc file -Encoding Byte)[0..2]看是否0xEF,0xBB,0xBF。
5. 开机自启(注册表 Run)
import winreg
def set_autostart(enable, exe_path):
key = winreg.OpenSubKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_SET_VALUE)
if enable:
val = '"%s" --minimized' % exe_path # --minimized 让开机自启后台跑
key.SetValue("SCADAMonitorManager", val)
else:
try: key.DeleteValue("SCADAMonitorManager")
except OSError: pass
key.Close()
- 用
HKEY_CURRENT_USER(无需管理员权限)。值带--minimized让开机自启不弹窗。
6. 关窗口 = 最小化(保活继续)
默认点 × 会销毁窗口、保活线程跟着没了。改成最小化到任务栏:
self.root.protocol("WM_DELETE_WINDOW", self.on_minimize)
def on_minimize(self):
self.root.iconify() # 仅最小化,保活线程继续运行
- 单独设「退出管理器」按钮做真正退出(退出后服务仍在跑,但保活暂停,重开即恢复);「停止系统」按钮才彻底停服务。
7. 状态显示用真实检测(修复"停止系统误报未运行")
真实事故:管理器用内存布尔 self.running 判断是否在跑。管理器被重启/多实例后,running 与实际进程脱节 → 点「停止系统」走 if not self.running 分支直接报"系统未运行"、根本没杀进程。
修复:启动时用 detect_running() 认领既有进程、同步 running 与按钮态;stop_system 闸门改为"内存标志未运行 且 真实进程都不在"才报未运行,否则按名称清理。
def stop_system(self):
py_ok, cf_ok = detect_running()
if (not self.running) and (not py_ok) and (not cf_ok):
self.write_log("系统未运行。")
return
self.running = False
# 按名称清理真实进程(见 win-powershell-envfix 的精确清理法)
...
8. 质量反例(不要这样写)
- ❌ 保活先
taskkill /F /IM cloudflared.exe无脑杀全部 → 多实例互杀风暴。 - ❌ 用
open(encoding='utf-8')读令牌文件 → BOM 字节混入导致认证失败。 - ❌ 只靠内存
running布尔判断状态 → 重启/多实例后状态脱节、停止失效。 - ❌ 用
netstat解析 LISTENING 的 PID 来定位进程 → 不可靠(曾误判成 8 误杀主进程)。改用Get-CimInstance按命令行匹配(见配套技能)。 - ❌ 在运行服务的同一个 venv 里装 pyinstaller → 污染运行环境。
9. 完成前检查清单
- [ ] 用独立 venv 的 pyinstaller 打包,
--onefile --windowed - [ ] 单实例互斥已加(重复启动被拦,开机自启静默退出不弹窗)
- [ ] 保活逻辑用
detect_running()真实状态,无"无脑 kill 全部" - [ ] 读令牌/配置统一
utf-8-sig - [ ] 开机自启写入
HKCU\...\Run(值带--minimized) - [ ] 关窗口 = 最小化(保活继续),有独立"退出/停止"按钮
- [ ] 状态显示基于真实进程检测,停止按钮能真正停掉
- [ ] 实测:启停正常、公网可达、模拟杀隧道后自动拉起、重启整机验证开机自启
10. 参考
- 配套技能
win-powershell-envfix:本技能"启动隧道/子进程"与"精确清理进程"依赖它的 PATH 折叠与进程清理法。 - 实际成品参考:
D:\工作空间\monitor_manager.py(本文所有片段的出处)。