无人值守AI内容矩阵实战(10):Windows 无 systemd,进程存活率 99% 怎么做

1 阅读5分钟

无人值守AI内容矩阵实战(10):没有 systemd 的 Windows,怎么把常驻进程的存活率做到 99%

一个让我凌晨爬起来的日志时间戳

第 8 天早上我打开机器,runner.log 的最后一行是 02:17:33 ERROR ...,之后再没有任何输出。也就是说,这条流水线在凌晨两点十七分就死了,剩下的六个小时里,本该发出的稿子一篇都没发。

那一刻我才意识到,我花了很多时间在「怎么让 AI 写出像人的稿子」上,却几乎没花时间在「怎么让写稿的进程活着」上。前者决定上限,后者决定下限,而下限是 0 的时候,上限毫无意义。

这篇是系列的终篇,讲 Windows 上没有 systemd 时,我最后落地的四个机制:5 秒轮询监管、文件级单实例互斥、10MB×3 日志轮转、VBS 静默启动。以及四个把我坑得最惨的编码与环境变量问题。

一、监管:为什么我没有选 Windows 服务

最"正统"的做法是把 Python 脚本注册成 Windows 服务(sc create 或 NSSM)。我试过,放弃了,原因有三个:

第一,服务跑在 Session 0,没有桌面会话。我的发布器基于 playwright-core 驱动系统 Edge,需要一个可交互的桌面会话才能正常启动浏览器。服务模式下直接报 browser has been closed。

第二,调试成本高。服务出问题只能看事件查看器,而我的日志是自己写的文件,两边对不上。

第三,也是最实际的:我只需要它在我登录之后跑。这台机器是我自己的开发机,不是服务器。

所以我选了 Start-Job 加轮询。核心逻辑就十几行:

$ErrorActionPreference = "Stop"
$py   = "C:\Python311\python.exe"
$work = "$HOME\workspace"
$tasks = @(
    @{ Name = "scheduler"; Script = "runner.py" },
    @{ Name = "publisher"; Script = "publisher_worker.py" }
)

while ($true) {
    foreach ($t in $tasks) {
        $running = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
            Where-Object { $_.CommandLine -like "*$($t.Script)*" }
        if (-not $running) {
            Write-Host "[$(Get-Date -Format 'HH:mm:ss')] restart $($t.Name)"
            Start-Process $py -ArgumentList $t.Script `
                -WorkingDirectory $work -WindowStyle Hidden
        }
    }
    Start-Sleep -Seconds 5
}

这里有个细节值得单独说:判断存活不能用进程名。我机器上常年挂着一个 Jupyter,Get-Process python 永远返回非空,守护逻辑就永远不会触发——这是我第一次写守护脚本时最蠢的一个错误,表面上"守护在跑",实际上从来没救过任何进程。必须用 Win32_Process 的 CommandLine 精确匹配脚本文件名。

实测拉起延迟:14:32:07 我 Stop-Process 杀掉 runner.py,14:32:11 新进程起来,间隔 4 秒。5 秒轮询的最坏情况就是 5 秒,对内容流水线完全够用。想更快就把 Start-Sleep 调到 1 秒,CPU 占用会从 0.2% 涨到 1% 左右,我觉得不值。

二、互斥:一次双进程事故,让我丢了一天的发布额度

第 8 天那次事故比进程死掉更严重。监管脚本被我误开了两份——一份在计划任务里,一份在我手动开的终端里。结果每个任务都被拉起了两次,两个发布器同时去点同一个平台的发布按钮,同一篇稿子提交了两次,平台直接风控,当天那个号一条都发不出去。

修法是文件级单实例锁。Windows 上我一开始想用 Global\ 命名 Mutex,但跨会话的行为比较绕(计划任务和交互式会话的命名空间不一样),最后选了更朴素的做法——独占创建锁文件:

import os, sys, psutil

LOCK_PATH = os.path.expanduser("~/workspace/.publisher.lock")

def acquire_single_instance():
    for _ in range(2):
        try:
            fd = os.open(LOCK_PATH, os.O_CREAT | os.O_EXCL | os.O_RDWR)
        except FileExistsError:
            try:
                pid = int(open(LOCK_PATH, encoding="utf-8").read().strip() or 0)
            except Exception:
                pid = 0
            if pid and psutil.pid_exists(pid):
                print(f"another instance running, pid={pid}, exit")
                sys.exit(0)
            os.remove(LOCK_PATH)   # 陈旧锁
            continue
        os.write(fd, str(os.getpid()).encode())
        os.close(fd)
        return True
    raise RuntimeError("cannot acquire lock")

那个「陈旧锁」分支是我用血换来的。进程被 taskkill /F 强杀时,finally 里的清理代码根本不会执行,锁文件会一直留着。我第一版没处理这个情况,结果强杀一次之后,守护脚本每 5 秒拉起一次、每次都被锁挡住退出,日志刷了几千行,而我还以为流水线在正常跑。

三、日志轮转:2.3GB 的日志把磁盘写满,SQLite 开始报错

没有 logrotate 的 Windows,日志是裸的。我的 runner.log 在第 12 天涨到 2.3GB,机器盘只剩 800MB,然后 SQLite 开始报 disk I/O error——因为我的幂等闸(第 9 篇写的那个)依赖 SQLite 记录已发标题。

Python 自带的 RotatingFileHandler 就够用:

import logging
from logging.handlers import RotatingFileHandler

def setup_logger(name, path):
    logger = logging.getLogger(name)
    logger.setLevel(logging.INFO)
    handler = RotatingFileHandler(
        path,
        maxBytes=10 * 1024 * 1024,   # 10MB
        backupCount=3,               # 保留 .1 .2 .3
        encoding="utf-8",
    )
    handler.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)s %(name)s %(message)s"
    ))
    logger.addHandler(handler)
    return logger

为什么不用自己 os.rename 实现?因为在 Windows 上,文件被占用时 os.rename 会抛 PermissionError。RotatingFileHandler 内部是先 close 再 rename,天然避开这个问题。这是 Windows 和 Linux 一个很实际的行为差异,我在 Linux 上从来没为这个操心过。

另外我加了一个小东西:每次轮转时往 heartbeat.json 写当前时间戳。看门狗读这个文件判断「流水线是不是还活着」,比去 grep 日志内容可靠得多,也不用担心日志被轮转掉。

四、GBK 乱码:中文日志全变问号,而且不报错

这个坑最隐蔽,我排查了两个小时。

现象:在 PowerShell 里跑 python runner.py,日志里的中文全是问号。我一开始以为是文件写入编码问题,改成 encoding="utf-8" 之后还是问号——因为问题根本不在写,在读。

根因是:Python 子进程的 stdout 编码继承自控制台代码页,中文 Windows 默认是 936(GBK)。而我的脚本里是 UTF-8 字符串,一输出就炸。更坑的是父进程用 subprocess.Popen(capture_output=True) 读子进程输出时,会按自己的默认编码去解码,两边不一致就得到一堆乱码,而且不抛异常。

我的处理是三层:

监管脚本里显式设环境变量并切代码页:

$env:PYTHONIOENCODING = "utf-8"
$env:PYTHONUTF8 = "1"
chcp 65001 | Out-Null

Python 侧读子进程输出时强制指定编码,并且加 errors="replace":

proc = subprocess.run(
    ["python", "runner.py"],
    capture_output=True,
    encoding="utf-8",
    errors="replace",
    env={**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"},
    timeout=1800,
)

errors="replace" 是第二道保险。之前用默认的 strict,一个字节解不出来就抛 UnicodeDecodeError,整个任务失败,而失败原因写在日志里也是乱码——这就是典型的"错误信息本身不可读",排查效率归零。

第三层:Start-Job 起的后台任务是独立 PowerShell 会话,父会话设的 $env: 不一定会继承。所以我在 job 脚本的第一行又设了一遍编码。这个重复看起来多余,但去掉之后乱码会回来。

五、环境变量污染:expanduser("~") 返回了一个不存在的路径

第 15 天,所有文件操作突然全失败。查下来是 os.path.expanduser("~") 返回了一个不存在的目录。

原因很蠢:我在同一个终端会话里调试过一段脚本,为了模拟另一个用户环境,临时改了 HOME 和 APPDATA,改完忘了恢复。后面在同一会话里跑的所有命令都带着被污染的变量。Windows 上 expanduser 优先读 USERPROFILE,其次 HOME,所以改 HOME 就足以让路径全歪。

两个修法我都做了。

一是启动子进程时用白名单环境,而不是 **os.environ 全量透传:

SAFE_KEYS = ("PATH", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE",
             "APPDATA", "LOCALAPPDATA", "PYTHONIOENCODING", "PYTHONUTF8")

def clean_env():
    return {k: os.environ[k] for k in SAFE_KEYS if k in os.environ}

二是代码里不再到处调 expanduser,工作目录统一从一个入口拿,展开一次后缓存,并且加硬校验:

WORKSPACE = None

def workspace():
    global WORKSPACE
    if WORKSPACE is None:
        raw = os.environ.get("MATRIX_WORKSPACE", "~/workspace")
        WORKSPACE = os.path.abspath(os.path.expanduser(raw))
        if not os.path.isdir(WORKSPACE):
            raise RuntimeError(f"workspace not found: {WORKSPACE}")
    return WORKSPACE

关键是那个 raise。以前路径不对的时候,代码会默默在错误的地方创建目录,跑起来看着一切正常,实际上数据全写到了别处,等发现的时候已经丢了几天的记录。加一条硬校验,错误立刻暴露。

六、静默启动:每次登录弹个黑框,关掉窗口进程就死

最后一件小事。把 guard.ps1 直接丢进启动文件夹,每次登录弹一个 PowerShell 黑窗口,而且我手贱关掉窗口,进程就跟着死了。

用 VBS 包一层:

' start-silent.vbs
Set ws = CreateObject("WScript.Shell")
ws.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ""C:\Users\dev\workspace\guard.ps1""", 0, False

第二个参数 0 是隐藏窗口,第三个 False 是不等待返回。放进 shell:startup 目录(Win+R 输入 shell:startup 直接打开),登录之后完全静默。

提醒一句:-ExecutionPolicy Bypass 会被部分安全软件标记。如果你的机器有 EDR,可能需要让 IT 把脚本目录加白。我这边是把 guard.ps1 的路径写死,没有做成通用脚本。

20 天的实际运行数据

指标数值
非预期退出7 次(5 次是我手动 kill 测试,2 次真异常)
平均拉起耗时4.1 秒(5 秒轮询,最坏 5 秒)
双进程冲突加锁前 1 次,加锁后 0 次
日志磁盘占用稳定 40MB 以内
需要人工介入的半夜故障0 次

这套东西不优雅,但它活了

没有容器、没有编排、没有健康检查端点,就是几个 PowerShell 循环加 Python 锁文件。但它解决了一个非常具体的问题:在一台会重启、会休眠、会被人手动关窗口的 Windows 开发机上,让两个进程老老实实待着。

回头看这 10 篇——架构拆解、幂等闸、重试风暴、看门狗、自动补发、IM 长消息被吞、Markdown 编辑器坑、标题 A/B、MCP 重构、Windows 守护——真正值钱的部分从来不是「AI 帮我写稿」,而是这些让流程不崩的工程细节。

如果只留一条经验:任何静默失败都当成最高优先级 bug 修。我两次真正的事故,一次是子进程输出解码出错但不抛异常,一次是路径错了但代码默默创建了新目录。它们都没报错,所以没人发现,等发现的时候已经损失了好几天。