雲端 Mac mini 架設 Swift Vapor 服務:launchd 常駐與零停機發佈

CI/CD 實踐 ·約 8 分鐘閱讀

雲端 Mac mini 架設 Swift Vapor 服務:launchd 常駐與零停機發佈

上個月團隊裡有一台 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 啟動的行程不會繼承你在 .zshrcexport 的變數,資料庫連線字串等設定要寫進 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 級權限,分鐘級交付,適合先跑通再決定要不要長租。

立即下單