上個月團隊裡有一台 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 跟直接用終端機跑起來有什麼差別?
終端機視窗關掉進程就會跟著消失,launchd 是系統層級的常駐管理機制,可設定 KeepAlive 自動重啟崩潰的進程,也能綁定開機啟動,更適合長期跑在雲端 Mac mini 上。
一定要開兩個埠才能做零停機發佈嗎?
同一埠重啟中間必然有幾秒服務空窗;用雙埠搭配 Nginx upstream,等新版本健康檢查通過才切流量,舊進程處理完既有連線再關閉,才能真正做到零停機。
資料庫要放在同一台 Mac mini 上嗎?
小型場景可在本機跑 PostgreSQL 或 SQLite 並靠每日快照備份;資料量大或多實例共用時,建議接獨立的託管資料庫,讓 Mac mini 只負責應用層。
在獨享 Mac mini 上實測
按天計費,root 級權限,分鐘級交付,適合先跑通再決定要不要長租。