无人值守AI内容矩阵实战(10):Windows 上没有 systemd,我如何让两个 Python 进程 7×24 活着
上周三凌晨 2 点 17 分,我打开笔记本,发现发布器已经死了 6 个小时。任务管理器里那个 python.exe 进程不见了,日志文件最后一行停在 [02:17:03] waiting for next slot。没有异常栈,没有退出码,什么都没有------就像它从来没存在过。
那一刻我才真正意识到,前面九篇里写的调度、幂等闸、A/B 判胜、补发闭环,全都建立在一个脆弱的前提上:进程得活着 。在 Linux 上这是 systemd 一行配置的事,Restart=always 加 RestartSec=5 就完了。但我这套东西跑在一台 Windows 机器上,没有 systemd,也没有 supervisor。
这篇是系列的终篇,我把自己在 Windows 上让两个常驻进程(调度器 scheduler.py 和发布器 publisher.py)7×24 活下来的全部踩坑记录写下来。代码都能直接跑,路径都做了脱敏,你可以照着改成自己的。
一、先把问题拆清楚:Windows 上到底缺什么
Linux 上 systemd 帮我做了四件事:进程崩溃自动重启、单实例保证、日志收集、开机自启。Windows 上一个都没有。任务计划程序能开机启动,但它不管进程死活;pythonw.exe 能隐藏窗口,但进程挂了它也不会拉起来。
我最终选的技术栈是 PowerShell 的 Start-Job 做监管循环,理由很实在:
- Windows 自带,不用装任何第三方服务框架;
-
- Start-Job 跑在独立 PowerShell 会话里,子进程崩了不会带崩监管者;
-
- 不用管理员权限就能注册登录自启(配合 VBS 隐藏窗口)。
先看监管脚本的核心,watchdog.ps1:
- 不用管理员权限就能注册登录自启(配合 VBS 隐藏窗口)。
powershell
# ~/workspace/scripts/watchdog.ps1
param(
[string]$Target = "python",
[string]$Script = "~/workspace/scheduler.py",
[string]$LockName = "Global\matrix_scheduler_lock",
[int]$IntervalSec = 5
)
$ErrorActionPreference = "Continue"
$logDir = "~/workspace/logs"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
# 单实例互斥:整个机器只允许一个监管者
$mutex = New-Object System.Threading.Mutex($false, $LockName)
if (-not $mutex.WaitOne(0)) {
Write-Host "another watchdog is already running, exit."
exit 0
}
while ($true) {
$startedAt = Get-Date
$proc = Start-Process -FilePath $Target `
-ArgumentList $Script `
-RedirectStandardOutput "$logDir\stdout.log" `
-RedirectStandardError "$logDir\stderr.log" `
-PassThru -NoNewWindow
$proc.WaitForExit()
$code = $proc.ExitCode
$alive = ((Get-Date) - $startedAt).TotalSeconds
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') exit=$code alive=${alive}s, restart in ${IntervalSec}s" |
Out-File -Append -Encoding utf8 "$logDir\watchdog.log"
Start-Sleep -Seconds $IntervalSec
}
```
这段代码里有两个细节值得说。第一个是 `WaitForExit()` 是阻塞的,所以监管循环天然就是「等死---重启」的节奏,不需要轮询。第二个是 `-PassThru` 必须加,不然拿不到进程对象,也就拿不到退出码。
实测数据:我在发布器跑到一半时从任务管理器强制结束进程,监管脚本在 **4.8 秒** 后重新拉起了新进程(间隔设的 5 秒,误差在 200ms 内)。日志里那一行是这样的:
2026-10-27 02:17:41 exit=-1 alive=18422s, restart in 5s
2026-10-27 02:17:46 exit=0 alive=0s, restart in 5s
第一行是被我杀掉的旧进程,退出码 -1;第二行是新进程刚起来就退出了------这就是下面要讲的第一个大坑。
## 二、坑一:双进程抢端口,互斥锁必须锁在业务进程上
上面日志第二行 `alive=0s` 说明新进程秒退。原因很简单:旧进程虽然被杀了,但 TCP 端口还处在 TIME_WAIT,新进程 bind 失败直接抛异常退出。更糟的情况是旧进程没死透,两个进程同时抢同一个端口,一个成功一个失败,失败的那个又触发重启,形成重启风暴------我在 20 分钟内看到 240 次重启记录,日志文件从 0 涨到 180MB。
监管者层面的 Mutex 只能保证「只有一个监管者」,保证不了「只有一个业务进程」。所以业务进程自己也得有一把锁。Python 侧我用的是命名互斥体:
```python
# ~/workspace/lock_guard.py
import ctypes
from ctypes import wintypes
ERROR_ALREADY_EXISTS = 183
def acquire_single_instance(name: str = "Global\\matrix_publisher_lock"):
"""返回 handle 表示拿到锁;返回 None 表示已有实例在跑。"""
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.restype = wintypes.HANDLE
kernel32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
handle = kernel32.CreateMutexW(None, True, name)
if not handle:
raise OSError(ctypes.get_last_error(), "CreateMutexW failed")
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
return None
return handle
```
用起来就一行:
```python
import sys, time
from lock_guard import acquire_single_instance
if __name__ == "__main__":
h = acquire_single_instance()
if h is None:
print("[skip] another publisher instance holds the lock")
sys.exit(0) # 注意:正常退出,别用非 0
main_loop()
```
这里有个我调了很久才想明白的点:**拿不到锁时必须 `exit(0)` 而不是 `exit(1)`**。因为监管者看到非 0 退出码会当成崩溃,5 秒后又拉起一个,又拿不到锁,又退出------无限循环。用 0 退出,监管者虽然也会重启,但至少日志里能清楚区分「业务正常退出」和「进程崩溃」。
端口层面再加一道保险,启动时先探测:
```python
import socket
def port_is_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", port))
return True
except OSError:
return False
```
## 三、坑二:GBK 把中文日志全变成问号
第二个坑更隐蔽。监管脚本里我用了 `-RedirectStandardOutput`,日志文件打开一看,中文全是 `??????`。
原因是 Windows 控制台默认代码页是 936(GBK),PowerShell 重定向子进程输出时按当前代码页解码,而 Python 3 默认往 stdout 写 UTF-8。两边编码对不上,中文就全花了。
我试过三种改法:
1. `chcp 65001` 切代码页------只在当前会话生效,Start-Job 起的子会话里经常不继承,不可靠;
2. 2. 在 Python 里 `sys.stdout.reconfigure(encoding="gbk")`------治标,遇到 GBK 表示不了的字符(比如 emoji、部分符号)直接抛 `UnicodeEncodeError`,进程崩掉;
3. 3. **在监管 job 内部显式设置 UTF-8 编码,再启动子进程**------这个才是正解。
最终版本在 watchdog 开头加三行:
```powershell
$env:PYTHONIOENCODING = "utf-8"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
同时 Python 侧不要依赖默认编码,日志文件明确写死:
python
import logging, io
handler = logging.FileHandler(
"~/workspace/logs/publisher.log",
encoding="utf-8", # 必须显式指定
delay=True,
)
handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s"
))
logging.basicConfig(level=logging.INFO, handlers=[handler])
```
改完之后,日志里终于能看到完整中文了:
2026-10-27 09:12:04 INFO 拉取热门方向完成,命中关键词 15 个
2026-10-27 09:12:31 INFO 人味分 78,通过
2026-10-27 09:13:02 INFO 发布成功 platform=csdn
顺便提一句,`FileHandler` 的 `delay=True` 也别省。默认情况下 handler 一创建就打开文件,如果你的日志轮转逻辑是「先 rename 再新建」,旧 handler 还握着旧文件句柄,Windows 上 rename 会直接失败(Linux 上不会,这是 Windows 特有的文件锁行为)。
## 四、坑三:日志轮转,10MB × 3 代
日志不轮转,一周就能把磁盘吃满。我一开始用 `logging.handlers.RotatingFileHandler`,结果在 Windows 上频繁报 `PermissionError: [WinError 32] 另一个程序正在使用此文件`。
问题出在轮转时它要 rename 当前文件,而监管者进程的 `-RedirectStandardOutput` 还开着同一个路径。解决办法是把 stdout/stderr 和业务日志彻底分开:stdout 只接崩溃栈,业务日志走自己的 handler,并且轮转参数这样配:
```python
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"~/workspace/logs/publisher.log",
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=3, # 保留 3 代
encoding="utf-8",
delay=True,
)
```
`10MB × 3 代` 意味着单进程日志上限 40MB,两个进程加起来 80MB,完全可控。我另外写了个每周清理任务,把 `watchdog.log` 里超过 30 天的行删掉------那个文件是纯文本追加,不需要轮转。
## 五、坑四:环境变量污染,路径 API 全错
这个坑是我花了最久才定位的,也是最反直觉的。
现象是:手动在 PowerShell 里跑 `python publisher.py` 一切正常,但通过监管 job 启动,所有路径都错,`os.path.expanduser("~")` 返回一个根本不存在的目录,`APPDATA` 指向别处,连 `tempfile.gettempdir()` 都报错。
排查过程很笨但有效------在脚本第一行把关键环境变量全打出来:
```python
import os, json
print(json.dumps({k: os.environ.get(k) for k in
("USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA", "TEMP", "PYTHONPATH")},
ensure_ascii=False, indent=2))
```
结果发现 `HOME` 被设成了某个临时目录。原因是之前我在同一个会话里跑过一段测试脚本,里面为了模拟干净环境临时改过 `HOME` 和 `APPDATA`,而这些改动被后续的 job 会话继承了。
教训很明确:
- **不要在交互会话里改 `HOME` / `APPDATA` / `USERPROFILE`**,要模拟就用 `subprocess` 的 `env=` 参数传一份副本;
- - 监管脚本启动子进程时,显式收敛环境变量,只传白名单:
```python
import os, subprocess
SAFE_KEYS = ("PATH", "SYSTEMROOT", "TEMP", "USERPROFILE",
"APPDATA", "LOCALAPPDATA", "PYTHONIOENCODING")
env = {k: os.environ[k] for k in SAFE_KEYS if k in os.environ}
env["PYTHONIOENCODING"] = "utf-8"
env["MATRIX_HOME"] = os.path.abspath("~/workspace")
subprocess.Popen(["python", "publisher.py"], env=env, cwd=env["MATRIX_HOME"])
- 业务代码里所有路径统一从一个入口取,别到处
os.path.expanduser:
python
# ~/workspace/paths.py
import os
WORKSPACE = os.environ.get("MATRIX_HOME") or os.path.abspath(".")
LOGS = os.path.join(WORKSPACE, "logs")
DRAFTS = os.path.join(WORKSPACE, "drafts")
STATE = os.path.join(WORKSPACE, "state.db")
for d in (LOGS, DRAFTS):
os.makedirs(d, exist_ok=True)
```
这一改之后,「本地能跑、守护进程跑不了」这类问题基本绝迹了。
## 六、登录即静默启动:VBS 藏窗口
最后一件事:机器重启后怎么自动起来,而且不弹黑窗口。
任务计划程序可以设「登录时触发」,但直接调 powershell 会闪一个控制台窗口。我用一个三行的 VBS 包一层,`startup.vbs`:
```vbscript
Set sh = CreateObject("WScript.Shell")
sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""~/workspace/scripts/watchdog.ps1""", 0, False
第二个参数 0 是隐藏窗口,第三个 False 表示不等待。把这个 vbs 的快捷方式丢进 shell:startup 目录(Win+R 输入即可打开),登录后静默拉起,任务管理器里只看得到 python.exe,看不到任何窗口。
验证方法我用了两天:重启机器,等 3 分钟,然后查日志里有没有新进程的启动记录,再 netstat -ano | findstr :8080 看端口是否被正常占用。连续 5 次重启,5 次都自动起来了。
七、20 天跑下来的真实数字
这套东西从 10 月 9 号上线到现在 20 天,我整理了几个能对得上的数字:
- 累计发布 61 篇,阅读 1890,收入 0 元;
-
- 监管脚本记录的重启次数 37 次,其中 29 次是业务正常退出(拿不到锁或任务完成),8 次是真崩溃;
-
- 最长连续无故障运行 6 天 11 小时;
-
- 单次故障平均恢复时间 5.2 秒,最慢一次 41 秒(那次是端口 TIME_WAIT 没释放,等了两个周期)。
收入是 0,这个我不打算粉饰。但流水线本身是稳的------它现在能在我完全不管的情况下,每天按时产出、发布、登记,出问题自己爬起来。对我来说,这就是「无人值守」这四个字的最低门槛。
- 单次故障平均恢复时间 5.2 秒,最慢一次 41 秒(那次是端口 TIME_WAIT 没释放,等了两个周期)。
系列到这篇就结束了。十篇写下来,最大的收获不是某个具体代码片段,而是意识到:自动化的可靠性从来不取决于最聪明的那部分,而取决于最笨的那部分能不能一直转。守护进程就是最笨的那部分。
如果你也在 Windows 上跑常驻服务,欢迎把你踩过的坑写在评论区,我大概率也踩过。