1. 先把需求看清楚:开机自启和崩溃重启其实是两件事
把 Mac mini 当 24 小时工作站跑 OpenClaw 的人,应该都遇到过这个场景:远程 SSH 上去部署好服务,一切正常,结果某天早上起来发现 agent 没反应;或者 Mac mini 自动更新重启了一次,所有进程全部归零,OpenClaw 也没了。更烦人的是跑着跑着进程自己崩了,没人手动拉就一直躺在那。
这个标题看着简单,实际拆开是两个需求:第一,开机之后 OpenClaw 要自己起来,不用我登录桌面或者开终端手动敲命令;第二,进程跑挂了之后要能自动恢复,而不是等我发现了再去救。这两个需求可以用一套方案同时解决,也可以用两套方案分别解决,但前提是你得先想明白自己要的是哪种。
我最终选择了 macOS 原生 launchd 的 KeepAlive 机制来做,没有用 pm2、没有用 Docker、也没有用第三方守护工具。这篇文章把思路、配置、踩坑的全过程写下来,给同样想把 Mac mini 变成“无人值守 AI 工作站”的人做个参考。
先交代一下背景:我的环境是 Mac mini M2,macOS Sequoia,OpenClaw 用源码方式部署在 /Users/xxx/openclaw 目录下,平时通过 openclaw start 启动服务。下面所有命令和路径,你按自己机器的实际情况替换即可。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:为什么最后选了 launchd,而不是 pm2 或 Docker
2.1 macOS 上常见的四种常驻方案横向对比
在 Mac mini 上让一个进程常驻,大体有四种思路:系统自带 launchd、Node 生态的 pm2、Docker 容器 restart 策略、以及最简单粗暴的“登录项 + 手动重启”。我把它们的特点整理成了表格,方便你对照选型:
| 方案 | 开机自启 | 崩溃重启 | 依赖 | 适合场景 |
|---|---|---|---|---|
| launchd(LaunchAgent) | 支持,跟随用户登录 | 支持 KeepAlive | 无,系统自带 | 本机单用户、直接跑进程,最推荐 |
| launchd(LaunchDaemon) | 支持,开机即起 | 支持 KeepAlive | 无,系统自带 | 需要 root 权限、不依赖用户会话的场景 |
| pm2 | 需配置 startup | 支持 | Node.js 环境 | 本身就是 Node 生态,进程管理功能丰富 |
| Docker + restart: unless-stopped | 支持 | 支持 | Docker Desktop 自启 | 服务已容器化,统一管理依赖 |
| 登录项 + 手动拉 | 支持(需登录) | 不支持 | 无 | 临时用用,不推荐 |
单看表格,pm2 和 Docker 好像都能干这件事,但我的实际情况决定了 launchd 是最优解。
OpenClaw 这个项目我平时是直接跑在宿主机上的,不是跑在容器里的。如果为了守护它再去套一层 Docker,那部署方式、网络模式、数据卷全都要跟着改,工作量和引入的新问题都不可控。pm2 的问题更直接,它本身依赖 Node.js,而我的 OpenClaw 用的是独立的 Python 虚拟环境,为了守护一个 Python 进程再装一套 Node 运行时,怎么看都亏。
2.2 launchd 的两个核心优势:零依赖和系统级保障
launchd 是 macOS 的原生服务管理框架,从系统启动流程的最早期就开始运作。选它的第一个理由是零依赖——不需要额外安装任何软件,plist 配置文件写好就能用。第二个理由是系统级保障,launchd 是内核为用户态进程提供的标准生命周期管理机制,它能在你的用户会话建立时就把服务拉起来,比任何第三方工具都更可靠。
有人可能会说:“我用一条 cron 加 @reboot 不也能开机自启吗?”能是能,但 cron 在 macOS 上只是个备选方案,它没有崩溃重启能力,而且对休眠唤醒、网络状态的处理都很粗糙。launchd 才是苹果官方推荐的常驻进程管理方式,社区里几乎所有 macOS 常驻服务的方案最终都会回归到 launchd。
这里多说一句:如果你本来就用 Docker 跑 OpenClaw,那完全没必要折腾 launchd,--restart unless-stopped 已经能覆盖开机自启和崩溃重启两个需求,前提是 Docker Desktop 本身设置了开机自启。而我这种宿主机直接跑源码的方式,用 launchd 是最干净的选择。
3. 动手前先搞懂 launchd 的关键概念,不然配置写出来都是玄学
3.1 LaunchAgent 和 LaunchDaemon 到底怎么选
launchd 有两类配置文件,路径不同,行为也不同:
- LaunchDaemon:放在
/Library/LaunchDaemons/,由 root 用户启动,开机时就会加载,不依赖任何人登录。适合需要高权限的系统级服务。 - LaunchAgent:放在
~/Library/LaunchAgents/(当前用户)或/Library/LaunchAgents/(所有用户),跟随用户登录后启动,以当前用户身份运行。
OpenClaw 是我自己用户目录下的服务,不需要 root 权限,也不需要访问其他用户的文件,所以我选的是 LaunchAgent,放在 ~/Library/LaunchAgents/ 下。
这里有个容易踩的坑:LaunchAgent 只有在用户登录后才会被加载。如果你的 Mac mini 设置了开机后停在登录界面,没有自动登录,那 LaunchAgent 是起不来的。我的机器是专用于跑服务的,设置了自动登录,所以没问题。如果你不想自动登录,那就得把服务放到 LaunchDaemon,然后用 launchctl asuser 切换用户运行,复杂度会高不少。
3.2 KeepAlive 的真实含义,它和“始终保活”是两回事
很多人对 KeepAlive 有误解,以为它是“让进程一直活着”。实际上 KeepAlive 是“当进程退出时,要不要把它重新拉起来”。它有几个常用写法:
KeepAlive直接写<true/>:不管进程是正常退出还是崩溃退出,都重新拉起来。KeepAlive写成字典{"SuccessfulExit": false}:只有非正常退出(退出码非 0 或者被信号杀死)才重启。如果进程自己正常结束了,说明它是主动退出的,不拉。KeepAlive写成{"Crashed": true}:只在崩溃时重启,和SuccessfulExit组合起来可以精确控制重启条件。
我用的是 {"SuccessfulExit": false},这样如果我自己手动停掉服务,launchd 不会跟我对着干;但如果进程是被 kill -9 干掉或者自己异常退出的,launchd 就会把它重新拉起来。这个语义在无人值守场景下非常合适。
3.3 必须同时理解的四个辅助参数
- RunAtLoad:设为
<true/>后,launchd 加载这份配置的那一刻就会启动进程。这是“开机自启”的关键开关。 - ThrottleInterval:控制两次重启之间的最小间隔,单位是秒。默认 10 秒。这个参数非常重要,防止进程启动后立刻崩溃导致 launchd 疯狂重启,把 CPU 干满。结合 OpenClaw 这种依赖网络、依赖模型 API 的 agent,我把间隔设成了 10 秒,既能快速恢复,又不会因为连续崩溃而刷爆日志。
- ProcessType:设成
Background或Interactive。背景服务建议显式声明Background,告诉系统这是一个低优先级后台任务,避免它影响用户日常操作。 - StandardOutPath / StandardErrorPath:把标准输出和标准错误重定向到日志文件。没有这俩,进程的所有输出都会被 launchd 丢弃,崩溃原因无处排查。
4. 实操全流程:从 wrapper 脚本到 plist 配置再到验证重启
4.1 先写一个启动脚本,把环境变量问题彻底解决
第一步不是直接写 plist,而是写一个启动脚本。为什么需要这个脚本?因为 launchd 启动进程时,环境是一个极简环境,不会加载你的 ~/.zshrc,PATH 变量短得可怜,也不会有你通过 Homebrew 安装的 Python、Node 等工具的路径。
如果直接在 plist 里写 openclaw start,很可能会遇到 command not found。我一开始就吃过这个亏,后来老老实实写了个 wrapper 脚本,在脚本里把事情都准备好:
bash复制#!/bin/bash
# /Users/yourname/scripts/openclaw-wrapper.sh
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/Users/yourname"
# 如果 OpenClaw 的启动依赖虚拟环境,把虚拟环境也激活一下
source /Users/yourname/openclaw/.venv/bin/activate 2>/dev/null || true
cd /Users/yourname/openclaw || exit 1
# 这里按你的实际启动命令替换
# --foreground 是为了保持进程在前台运行,方便 launchd 接管生命周期
exec openclaw start --foreground
注意几个关键点。
第一,export PATH 必须写全。Homebrew 在 Apple Silicon 上的默认路径是 /opt/homebrew/bin,Intel 的是 /usr/local/bin,你机器上什么情况自己确认一下。第二,source activate 要加 || true,避免虚拟环境不存在时整个脚本退出。第三,cd 到项目目录很重要,很多服务对相对路径有依赖。最后,exec 关键字意味着脚本进程会被 openclaw 进程替换掉,这样 launchd 监控到的就是 OpenClaw 本体,而不是包裹它的 shell 脚本。如果不加 exec,launchd 的 KeepAlive 检测到的是 shell 进程,shell 退出时子进程可能就成了孤儿进程,崩溃重启的语义就乱了。
写完脚本后先手动执行一遍,确认能正常启动,再继续下一步。
4.2 编写并安装 plist 配置文件
启动脚本没问题后,写 plist 配置文件。我在 ~/Library/LaunchAgents/ 下创建了 com.local.openclaw.plist,内容如下:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.local.openclaw</string>
<key>ProgramArguments</key>
<array>
<string>/Users/yourname/scripts/openclaw-wrapper.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
<key>StandardOutPath</key>
<string>/Users/yourname/logs/openclaw.stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/yourname/logs/openclaw.stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
这里解释几个容易困惑的点。
Label 是全系统唯一的标识,建议用反向域名风格,避免和其他服务冲突。ProgramArguments 是启动命令和参数组成的数组,数组第一个元素必须是可执行文件的绝对路径,后续元素是传给它的参数。这里我让 launchd 直接调用 wrapper 脚本,所有参数都在脚本里处理。
plist 的格式本质上是一个 XML 文件,属性字典的 key 是有固定写法的,拼写错误 launchd 会直接忽略整个键,而且不会报错,你只会发现“为什么配置没生效”。写完之后可以先跑一下 plutil -lint ~/Library/LaunchAgents/com.local.openclaw.plist 校验格式。
日志目录要确保存在且当前用户有写权限。我遇到过忘记创建 ~/logs 目录,launchd 也没报错,但日志就是不落盘,排查了半天才反应过来是目录不存在。
4.3 用 launchctl 加载配置并验证自启
现代 macOS 上推荐用 bootstrap 系列命令来加载服务,而不是老式的 load。两者的区别在于,bootstrap 是面向用户的会话域操作,语义更清晰;load 是旧时代的兼容方式,在某些系统版本上行为不一致。
bash复制# 加载服务
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.openclaw.plist
# 查看服务状态
launchctl print gui/$(id -u)/com.local.openclaw
# 查看正在运行的进程
pgrep -fl openclaw
gui/$(id -u) 指定的是用户图形会话域,对 LaunchAgent 来说是正确的位置。加载成功后,由于 RunAtLoad 为 true,OpenClaw 会立刻被启动。用 launchctl print 可以查看服务的运行状态、PID、退出码等详细信息。
如果你之前用老式命令加载过同一份 plist,再用 bootstrap 会报 “service already loaded” 的错误。这种情况下要么先用 bootout 卸载,要么干脆用 launchctl load -w 加载一次让它兼容。我建议既然是新配置,一条路走到底,统一用 bootstrap / bootout 命令。
4.4 关键验证:模拟崩溃,看它能不能自己爬起来
配置完并不是结束。开机自启和崩溃重启这两个能力,必须实际验证过才算数。
验证开机自启最简单的办法是重启 Mac mini,然后等一两分钟 SSH 进去看进程有没有起来。不过每次都重启机器太麻烦了,我平时更爱用验证崩溃重启的方式间接确认整个链路是通的。
bash复制# 找到 OpenClaw 的进程 PID
pgrep -fl openclaw
# 模拟崩溃:用 kill -9 直接杀掉
kill -9 <PID>
执行 kill -9 后,观察日志和进程:
bash复制# 看标准输出日志
tail -f ~/logs/openclaw.stdout.log
# 确认进程是否被重新拉起
pgrep -fl openclaw
我的实测结果是:kill 之后大约 1-2 秒,launchd 就重新拉起了进程,日志里能看到新的启动记录。这个间隔主要由 ThrottleInterval 控制,10 秒的冷却时间足够避免疯狂重启,感知上又足够快。
如果 kill 之后进程没起来,看三件事:第一,launchctl print gui/$(id -u)/com.local.openclaw 里显示的 last exit status 是什么;第二,stderr 日志里有什么报错;第三,配置文件里 KeepAlive 是不是真的生效了。绝大部分“不起”都是这三个原因。
4.5 开机自启的完整清单自查
在结束实操之前,把开机自启涉及的所有环节列一个自查清单,我每次换机器迁移配置都会过一遍:
- [ ] wrapper 脚本有可执行权限(
chmod +x) - [ ] plist 文件放在正确的 LaunchAgents 目录
- [ ] plist 格式校验通过(
plutil -lint) - [ ] 日志目录已创建且用户可写
- [ ] 自动登录已开启(LaunchAgent 方案的前提)
- [ ] 重启后验证进程确实自动启动
- [ ] kill 后验证进程确实自动恢复
这套清单看着简单,但我见过太多人忽略了第一条和第五条,折腾半天发现是权限或登录问题。
5. 运维中的坑:环境变量、重复实例、会话域,一个比一个隐蔽
5.1 环境变量缺失导致 OpenClaw 无法找到模型配置
把服务交给 launchd 后,最大的一个变化是环境变量不再继承自你的交互式 shell。OpenClaw 读取的 API Key、模型提供商配置等,如果写到 ~/.zshrc 里,launchd 启动的进程根本看不到。
这也是我在 wrapper 脚本里保留 source ~/.zshrc 2>/dev/null || true 这行的原因。但这里有个隐患:.zshrc 里如果有任何交互式逻辑,比如检查终端类型、打印欢迎信息,source 的时候会出问题。我后来干脆把所有 OpenClaw 需要的环境变量直接写进了 wrapper 脚本:
bash复制export ANTHROPIC_API_KEY="sk-xxx"
export DEFAULT_MODEL="claude-sonnet-4-5"
这类变量直接写死在脚本里反而最省心。如果你是靠 .env 文件管理配置的,在 wrapper 脚本里 set -a 后 source .env 再 set +a 也行,确保 launchd 运行时能加载到。
5.2 重复实例:KeepAlive 和守护模式打架
OpenClaw 这类服务往往自带 daemon 模式,比如 openclaw start 可能会自己 fork 到后台,shell 立刻返回。如果启动命令这样写,问题就来了:launchd 启动的 wrapper 脚本很快退出(因为真正的进程被 fork 走了),KeepAlive 判定“进程退出了”,于是再次启动 wrapper,又 fork 一个进程。循环往复,最后你会看到十几个 OpenClaw 实例同时跑着。
解决办法有两个:一是启动命令加 --foreground,强制服务保持前台运行,让 launchd 能正确监控到主进程;二是把 wrapper 脚本设计成不 fork、不 daemonize,始终让 exec 替换后的进程在前台跑。我最终是通过 openclaw start --foreground 解决的,这也是为什么我在前面脚本示例里特意标出这个参数。
5.3 老版 load 和新版 bootstrap 混用的坑
macOS 版本升级后,launchctl 命令的行为也变了,网上教程更是新旧混杂。有些人用 launchctl load -w 加载,后来想卸载,又用 launchctl bootout,结果提示找不到服务,因为加载方式不匹配导致服务所在的域不同。
我的建议是统一使用新版命令:
bash复制# 加载
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.openclaw.plist
# 卸载
launchctl bootout gui/$(id -u)/com.local.openclaw
# 重新加载(改完配置后最常用)
launchctl bootout gui/$(id -u)/com.local.openclaw
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.openclaw.plist
改完 plist 文件后,必须执行 bootout 再 bootstrap 才能让新配置生效。只改文件不重载的话,launchd 一直用内存里的旧配置,这也是日常开发中最常见的“我明明改了为什么不生效”的原因。
5.4 网络依赖服务的顺序问题
OpenClaw 启动时要连接模型 API、可能还要拉取消息,这些都需要网络。而 launchd 启动服务的时机不一定在网络就绪之后。你可能会遇到这种情况:开机后进程起来了,但日志里全是网络错误,过一会儿才恢复。
最常见的表现是,服务起得比 Wi-Fi / 以太网晚,SSL 握手失败。处理方式有几种:一是在 OpenClaw 内部配置断线重试,这个多数项目都有;二是接受现状,反正它崩溃了 launchd 会拉起来;三是写一个等网络的脚本,先 ping 网关通了再启动服务。我的做法比较务实,OpenClaw 本身具备重试逻辑,网络抖动导致的启动失败会自动恢复,所以没有额外处理。
5.5 日志文件不断膨胀的问题
常驻服务跑久了,日志文件会越滚越大。我的 OpenClaw 日志每几天就能到几百 MB。不处理的话,磁盘会被写满,服务自己就崩了。
我通常在 macOS 上配合 newsyslog 或 logrotate 做日志轮转,简单点的话,写一个 cron 任务定期把日志归档清空。更省心的方式是只保留最后 N 行:
bash复制# 每天 3 点清空超过 50MB 的日志
0 3 * * * test $(du -m ~/logs/openclaw.stdout.log | awk '{print $1}') -gt 50 && > ~/logs/openclaw.stdout.log
这个方案不优雅,但很实用。日志只是排查用的,不是资产,留最新内容就够了。
6. 进阶一点:当 KeepAlive 不够用时,加一层健康检查
6.1 进程活着但服务卡死,这是 KeepAlive 管不了的
KeepAlive 只能检测进程是否退出,不能检测服务是否健康。OpenClaw 可能遇到进程还在,但内部的 Agent 循环卡死、消息队列堵塞、对外的 API 不再响应的情况。这时候 launchd 不会重启任何东西,因为进程的退出码还是 0,状态看起来一切正常。
如果你对服务可用性要求高,就需要在 launchd 之外加一层健康检查。思路是写一个探活脚本,定期去请求 OpenClaw 的健康检查接口,如果连续几次失败就直接 kill 进程,让 launchd 的 KeepAlive 把它重新拉起来。
6.2 用 StartInterval 实现定时健康检查
在原有的 plist 里再加一个定时任务不现实,一个 LaunchAgent 只能干一件事。所以我会再写第二个 LaunchAgent,专门做定时探活:
bash复制#!/bin/bash
# /Users/yourname/scripts/openclaw-healthcheck.sh
HEALTH_URL="http://127.0.0.1:8080/health"
FAILED=0
for i in 1 2 3; do
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
exit 0
fi
sleep 2
done
# 连续失败,kill 掉主进程,让 launchd 重启它
pgrep -f "openclaw start" | xargs kill -9
对应的 plist 里用 StartInterval 设为 60 秒,RunAtLoad 设为 true,这样每 60 秒检查一次。这个方案能覆盖“进程活着但服务死了”的场景,代价是要额外维护一个探活脚本。对我来说,OpenClaw 跑的都是常规任务,进程级重启已经够用,健康检查可以作为后续加固方向。
7. 最后一个建议:把 OpenClaw 的升级流程和自启配置解耦
我之前踩过一个大坑:升级 OpenClaw 版本后,启动命令的路径变了,plist 里写死的路径失效,服务怎么都起不来。后来我养成了一个习惯,升级后第一时间验证 wrapper 脚本是否还能正常执行,而不是只验证 OpenClaw 本身的命令。
我的升级流程固定为四步:先 bootout 停服务,再升级代码或安装包,然后手动跑一遍 wrapper 脚本确认启动正常,最后 bootstrap 重新加载。这套流程看起来多了一步,但能避免“升级一时爽,服务火葬场”的情况。Mac mini 上跑常驻服务,稳定压倒一切,任何变更都要可回滚、可验证。
回到开头说的那个需求:开机自启和崩溃重启,用 launchd 一套配置全解决了。我实际跑下来,连续运行了二十多天没人工干预,中途系统自动重启过一次,开机后 OpenClaw 自己就回来了。现在我已经完全不用 SSH 上去手动拉服务了,需要看状态就看一眼进程和日志。如果你的 Mac mini 也在跑 OpenClaw 或者其他常驻服务,照着这套思路配一遍,应该能省下不少半夜爬起来救服务的经历。
