上个月团队的一台 MacBook Pro 被拿去当"临时后端"用:上面跑着一个给 iOS App 做本地 API 调试的 Swift Vapor 服务,swift run 起来之后开着终端窗口就不敢关。结果一次系统更新自动重启,服务悄无声息地挂了大半天,QA 同事连不上接口还以为是自己网络问题。这种"人肉守护进程"的方式,在租一台常年在线的云端 Mac mini 之后必须换掉——本文记录把 Vapor 服务从终端里搬到 launchd 常驻、再做到零停机发布的完整过程。
场景:为什么把 Vapor 服务放到云端 Mac mini
Swift Vapor 常被当作"给 iOS 客户端配套的轻量后端":假登录、推送回调、埋点接收、内部管理后台。这类服务体量不大,但要求 7×24 在线,且必须是真机 macOS 环境(有的场景要联调 APNs 证书、要跑 swift build 的原生依赖)。云端 Mac mini 独享物理机、非虚拟机,恰好补上"本地机器不能常开、云上 Linux 又跑不了 macOS 专属依赖"这块缺口。但独享一台裸机之后,进程管理的责任也全部转移到你自己身上——没有平台托管层帮你重启崩溃进程,得靠 launchd 自己搭。
环境准备:工具链与端口规划
登录后先确认工具链版本,避免本机开发环境与云端不一致导致编译产物行为差异:
swift --version
xcode-select -p
mkdir -p ~/apps/vapor-api/releases
mkdir -p ~/apps/vapor-api/logs
规划端口时预留双端口,给后面的零停机切换做准备:
| 用途 | 端口 | 说明 |
|---|---|---|
| 生产主端口 | 8080 | Nginx 对外反代的当前活跃版本 |
| 灰度/新版本端口 | 8081 | 新构建先在此端口自检 |
| 内部健康检查 | 8080/8081 /healthz |
Vapor 路由自定义,返回构建版本号 |
/healthz 路由建议直接返回 Git commit 短哈希,方便发布时肉眼确认切换到了正确版本,不用去猜进程是不是真的重启了。
用 launchd 常驻服务替代 nohup
把 ~/Library/LaunchAgents/com.m4rent.vaporapi.plist 写成这样:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.m4rent.vaporapi</string>
<key>ProgramArguments</key>
<array>
<string>/Users/deploy/apps/vapor-api/current/Run</string>
<string>serve</string>
<string>--hostname</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>8080</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/deploy/apps/vapor-api/current</string>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/deploy/apps/vapor-api/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/deploy/apps/vapor-api/logs/stderr.log</string>
</dict>
</plist>
加载与验证:
launchctl load ~/Library/LaunchAgents/com.m4rent.vaporapi.plist
launchctl list | grep vaporapi
curl -s localhost:8080/healthz
plist 关键字段解析
KeepAlive 设为 true 后,进程异常退出会被 launchd 自动拉起,不需要写额外的守护脚本;但要小心配置文件本身写错导致启动即崩溃时,launchd 会陷入"秒重启"的死循环,这时看 launchctl list 里的重启次数比看日志更快定位问题。RunAtLoad 保证开机/登录后自动拉起,配合云端 Mac mini 全年无计划停机的运行方式,基本不用再手动干预启动流程。WorkingDirectory 一定要显式写,否则相对路径的配置文件、日志路径会因为 launchd 默认工作目录不是你预期的位置而读错。
零停机发布:双端口 + Nginx 反向代理切换
发布新版本时不要直接覆盖 current 目录后重启同一个进程,那样中间必然有几秒钟的服务空窗。改用"新端口起新进程,确认健康后切流量"的方式:
cp -R releases/build-2026-07-12 releases/build-2026-07-12-verify
sed -i '' 's/8080/8081/' com.m4rent.vaporapi-staging.plist
launchctl load com.m4rent.vaporapi-staging.plist
curl -s localhost:8081/healthz
/healthz 返回预期的 commit 哈希后,再切 Nginx upstream:
upstream vapor_api {
server 127.0.0.1:8081;
}
nginx -s reload 只会重新加载配置,不会中断已建立的连接,旧的 8080 进程处理完存量请求后再用 launchctl unload 优雅下线。
千万别在切流量之前就把旧端口停掉——健康检查通过不代表新版本在真实负载下没问题,留出至少几分钟的观察窗口,发现异常一条命令切回旧 upstream 比事后回滚快得多。
日志与监控:避免磁盘写满
launchd 重定向的 stdout.log/stderr.log 默认不会自动轮转,长期运行的服务几个月就能攒到几 GB。用系统自带的 newsyslog 加一条规则:
/Users/deploy/apps/vapor-api/logs/stdout.log deploy:staff 644 7 10240 * N
这条规则表示日志超过 10MB 就轮转一次,最多保留 7 份历史文件。云端 Mac mini 的 SSD 容量是固定的,日志失控直接影响的是构建产物和快照空间,建议每周用 du -sh ~/apps/vapor-api/logs 扫一眼增长趋势。
踩坑清单
- 环境变量丢失:launchd 启动的进程不会继承你在
.zshrc里export的变量,数据库连接串等配置要写进 plist 的EnvironmentVariables字典,或者用独立的.env文件在代码里显式加载。 - 端口占用检测不到:
launchctl load报"服务已存在"却连不上端口,通常是上一次异常退出留下了僵尸 plist 记录,先launchctl remove再重新load。 - KeepAlive 死循环:配置文件路径写错导致进程秒退,
KeepAlive会让它无限重启,CPU 占用瞬间飙高,先launchctl unload止血再排查。 - 发布脚本没有回滚点:
current目录建议用符号链接指向具体的releases/build-*目录,回滚就是把链接指回上一个目录再 reload,不要用覆盖式发布。
常见问题
launchd 和直接用 nohup/screen 跑 Vapor 服务有什么本质区别?
nohup 只是让进程脱离终端,但进程崩溃或机器重启后不会自动拉起;launchd 是 macOS 的系统级进程管理器,能设置 KeepAlive 自动重启崩溃进程,还能绑定登录/开机自启,更适合长期跑在云端 Mac mini 上的服务。
零停机发布一定要用两个端口吗?能不能直接重启同一个端口?
同端口重启在新进程完全启动前必然有几秒空档,期间请求会连接失败;双端口(如 8080/8081)配合 Nginx upstream 切换,可以在新版本健康检查通过后才切流量,旧进程处理完存量请求再下线,真正做到零停机。
云端 Mac mini 上运行 Vapor 服务,数据库放哪里比较合适?
轻量场景可以在同一台机器上跑本地 PostgreSQL 或 SQLite,配合每日快照做备份;数据量较大或要多实例共享时,建议连到独立的托管数据库,Mac mini 只负责应用层,避免单机故障影响数据。
在独享 Mac mini 上验证
按天起租,root 级权限,分钟级交付,适合先跑通再决定是否长租。