從 Linux 轉到 macOS 工作,多半會在某個瞬間卡住:想開機自動跑一個同步腳本,反射性地打 systemctl enable,結果 macOS 根本沒有 systemctl;想排程一個每天的備份,習慣性地 crontab -e,雖然還能用,但會發現 Apple 文件一直叫大家別再用 cron。macOS 背後其實有一套完整、而且設計得相當早的服務管理系統,核心就是 launchd 這一個 process。理解它,幾乎等於理解整個 macOS 是怎麼開機、怎麼把背景服務管起來的。

這篇用三張圖解,從 launchd 的多重身分講起,依序走過 plist 宣告式設計、system/gui/user 三種 Domain 的差別、On-demand 隨需啟動機制、KeepAlive 條件式重啟、launchctl 新舊語法與除錯,最後整理 2026 年這個系統有哪些新變化,並實際寫一個會自己定時跑的 LaunchAgent。對熟悉 Linux 的開發者們,文章會盡量用 systemd、inetd、cron 來對照,建立心智模型最快。

launchd 是什麼:一個 process 扮演四種身分

在 Linux 上,「開機啟動服務」「有連線才拉起服務」「定時排程」「程序掛掉自動重啟」這四件事,分別由 init/systemd、inetd/xinetd、cron、supervisor 這幾套獨立工具負責。macOS 的選擇剛好相反:把這四種職責收斂進同一個 daemon。launchd 是 kernel 開機後啟動的第一個使用者空間程序,PID 永遠是 1,之後所有服務都是它的子孫。

Linux 用 init/systemd、inetd、cron、supervisor 四套獨立工具,macOS 由 launchd 一個 process 收斂這四種職責
Linux 把服務管理拆成四套工具,macOS 則由 launchd 一個 process 同時扮演 PID 1、socket 觸發、定時排程與程序監看。

這個設計帶來一個關鍵差異:在 launchd 的世界裡,服務不負責「怎麼啟動自己」,只負責「描述自己該在什麼條件下被啟動」。要不要常駐、要不要開機跑、被連線打到才起來、還是每天三點跑一次,全部寫成設定丟給 launchd,由 launchd 統一決定何時 fork、何時回收。這跟 systemd 的哲學其實很接近,但 launchd 早了好幾年——它在 2005 年的 Mac OS X 10.4 就登場,那時 systemd 連影子都還沒有。

一切都是 plist:宣告式設計

launchd 沒有 SysV 那種開機 shell 腳本,也沒有 systemd unit 那種半宣告半指令的混合語法。每個服務就是一個 property list(plist)檔案,副檔名 .plist,內容是一份 XML(系統內部會轉成 binary plist 加速讀取)。這份檔案描述「這個服務是什麼、執行什麼程式、什麼條件下該被拉起來」,啟動邏輯完全不寫在裡面。

一個最小的 LaunchAgent 大概長這樣:

<?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>tw.klab.backup</string>

    <!-- 實際要執行的程式與參數,第一個元素是執行檔絕對路徑 -->
    <key>ProgramArguments</key>
    <array>
        <string>/Users/kyle/bin/backup.sh</string>
        <string>--full</string>
    </array>

    <!-- 載入後立刻跑一次 -->
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>

幾個最常用到的 key 整理如下,其餘都可以查 man launchd.plist,這份 man page 是目前最權威、也最該優先讀的來源:

key用途
Label服務唯一識別名,必填,慣例用反向網域(如 tw.klab.backup
ProgramArguments要執行的指令與參數陣列,第一個元素必須是絕對路徑
RunAtLoad載入時是否立刻跑一次
KeepAlive是否(以及在什麼條件下)保持常駐、掛掉自動重啟
StartCalendarInterval定時排程,取代 cron
StartInterval每隔幾秒跑一次
WatchPaths監看路徑,內容變動就觸發
Sockets宣告 socket,有連線才拉起服務(取代 inetd)
StandardOutPath / StandardErrorPath把 stdout/stderr 導去檔案,除錯必備

這裡有個 Linux 使用者很容易踩的坑值得先講:ProgramArguments 的第一個元素一定要是絕對路徑,而且 launchd 跑服務時的環境變數極度精簡——沒有平常 shell 的 PATH、沒有 ~/.zshrc 載入的那些設定。很多人寫好腳本手動跑沒事,丟給 launchd 就「沒反應」,十之八九是腳本裡某個指令找不到,因為 PATH 不是想像中的那個。所以實務上腳本內最好都用絕對路徑,或在腳本開頭自己 export PATH

Domain:服務跑在哪裡,決定它能做什麼

launchd 最容易讓人混淆的概念是「Domain(網域)」。同一份 plist,放在不同位置、載入到不同 domain,權限與生命週期完全不同。這也是「LaunchDaemon 跟 LaunchAgent 到底差在哪」這個老問題的答案來源。

launchd 的 system、gui、user 三種 domain 分層,分別對應 LaunchDaemons 與 LaunchAgents 的 plist 放置位置與權限差別
服務載入到哪個 domain,決定它以什麼身分執行、何時存在;plist 的擺放目錄決定它屬於哪個 domain。

三種 domain 的差別可以這樣理解。system domain 的服務以 root 執行、開機就在、沒有使用者介面,這類服務叫 LaunchDaemon,典型用途是網路服務、系統層背景任務。gui/<uid> domain 綁定「某個使用者已經登入圖形桌面」這個前提,能存取視窗環境,這類服務叫 LaunchAgent,典型用途是選單列小工具、雲端同步程式。user/<uid> domain 則綁定使用者身分但不需要圖形桌面,概念上最接近 Linux 後來才補上的 systemd --user

plist 放在哪個目錄,就決定它屬於哪個 domain:

目錄類型身分/時機誰放的
/Library/LaunchDaemons/LaunchDaemonroot,開機即起,無 GUI第三方軟體、管理者
/System/Library/LaunchDaemons/LaunchDaemon同上Apple 內建,唯讀
/Library/LaunchAgents/LaunchAgent任一使用者登入後第三方軟體、管理者
~/Library/LaunchAgents/LaunchAgent該使用者登入後使用者自己
/System/Library/LaunchAgents/LaunchAgent同上Apple 內建,唯讀

所以 Daemon 與 Agent 的差別,重點其實不在「是不是常駐」——兩者都可以常駐、也都可以隨需啟動——而在「跑在哪個 domain、有沒有綁定使用者登入」。需要在沒人登入時就運作(例如開機後立刻要起的網路服務),就得是 system domain 的 LaunchDaemon;需要碰到使用者的視窗、剪貼簿、通知中心,就只能是 LaunchAgent。這個判斷選錯,常見症狀是「Agent 寫成 Daemon,結果完全碰不到 GUI」或「Daemon 寫成 Agent,結果使用者沒登入時服務根本沒在跑」。

On-demand:launchd 最核心的設計

如果只能記 launchd 一個設計,那就是 On-demand(隨需啟動)。服務不一定要在開機時就跑,而是「被需要時才被拉起來」,這在 launchd 是一等公民,不是事後補的功能。觸發條件可以是 socket 有連線、某個路徑的檔案變動、到了排程時間,或其他程式透過 IPC 點名要它。

其中最經典的是 socket activation。它的運作方式是這樣:

On-demand 啟動流程:launchd 先代服務持有 socket,有 client 連線進來才 fork/exec 出實際服務,並把 socket fd 交棒過去
launchd 開機時先代服務持有並 listen socket,真有連線進來才把服務 fork 起來,並把已建立的 socket fd 交棒給它。

關鍵在第一步:launchd 在服務還沒跑的情況下,就先替它把 socket 開好並 listen。閒置服務因此完全不占記憶體、開機也不會被一堆「其實沒人用」的服務拖慢。等真的有連線打進來,launchd 才 fork/exec 出實際的服務程序,並把那個已經建立好的 socket file descriptor 透過繼承交給它,服務一啟動就能直接收這條連線。systemd 後來的 socket activation,本質上就是這套設計的再實作。

定時排程則用 StartCalendarInterval,這是 macOS 上取代 cron 的官方做法。它比 cron 多一個重要特性:如果排程時間點電腦正在睡眠,cron 會直接錯過那一次,StartCalendarInterval 則會在電腦醒來後補跑一次。對筆電這種經常闔上蓋子的裝置,這個差別很實際——這也是 Apple 一直建議別再用 cron 的主因之一。

KeepAlive:比 systemd Restart= 更細的條件式重啟

常駐與自動重啟由 KeepAlive 控制。最簡單的寫法是布林值 true,意思是「只要這服務不在,就把它拉起來」,等同一個永遠重啟的 daemon。但 KeepAlive 真正強的地方是它可以是一組條件字典,表達力比 systemd 的 Restart= 那幾個列舉值細很多:

<key>KeepAlive</key>
<dict>
    <!-- 只有上一次以非 0 退出(失敗)才重啟;正常結束就放它走 -->
    <key>SuccessfulExit</key>
    <false/>

    <!-- 只有這個檔案存在時才保持服務存活 -->
    <key>PathState</key>
    <dict>
        <key>/var/run/myservice.enabled</key>
        <true/>
    </dict>
</dict>

上面這個例子的語意是:服務正常結束(exit 0)就讓它結束、不重啟;只有它失敗(非 0 退出)時才拉回來;而且整段重啟邏輯只在 /var/run/myservice.enabled 這個檔案存在時才生效。用一個檔案的存在與否當開關,是 launchd 圈子裡很常見的手法。其他還有 NetworkState(網路通才保持存活)等條件。這種「條件式存活」的細緻度,是 launchd 設計上明顯比早期 systemd 強的地方。

launchctl:新舊語法與除錯

操作 launchd 的指令是 launchctl。這裡有個必須講清楚的歷史包袱:launchctl 有新舊兩套語法,網路上的教學文章混雜得很嚴重,照舊文章操作常常會遇到「指令還能用但行為跟說明不一樣」的情況。

動作舊式語法新式語法(建議)
載入服務launchctl load <plist>launchctl bootstrap <domain> <plist>
卸載服務launchctl unload <plist>launchctl bootout <domain> <plist>
立刻觸發一次launchctl start <label>launchctl kickstart <domain>/<label>
查看狀態launchctl listlaunchctl print <domain>

新式語法強制要指明 domain,例如使用者層級的服務是 gui/501(501 是 id -u 看到的 uid),系統層級是 system。概念上比舊語法乾淨——舊式 load 會自己「猜」要載到哪個 domain,猜錯的後果很難 debug;新式逼開發者把 domain 寫明白。常見的新式操作長這樣:

# 取得自己的 uid(後面的 gui/<uid> 會用到)
id -u
# 假設輸出 501

# 載入並啟動一個使用者層級 LaunchAgent
launchctl bootstrap gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist

# 查看這個服務目前狀態、上次 exit code、為什麼沒被拉起來
launchctl print gui/501/tw.klab.backup

# 不等觸發條件,手動踹它跑一次(-k 代表先砍掉再重跑)
launchctl kickstart -k gui/501/tw.klab.backup

# 改完 plist 後要重載:先 bootout 再 bootstrap,不能只 bootstrap
launchctl bootout gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist
launchctl bootstrap gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist

除錯時 launchctl print 是最該先打的指令,它會印出服務當下狀態、最後一次的 exit code、以及 launchd 認為它為什麼該或不該在跑。配合 plist 裡設好的 StandardOutPathStandardErrorPath,九成的「服務沒反應」都查得出來。注意:改完 plist 不會自動生效,一定要 bootoutbootstrap 重載一次,這是另一個從舊 load 習慣過來的人很常忘的點。

2026 年的最新變化

launchd 的核心模型十幾年沒變,但周邊這幾年有幾個方向性的調整,2026 年寫的程式應該知道:

SMAppService 取代手動丟 plist。對 App 開發者來說,Apple 從 macOS 13 起推的 SMAppService API(取代更早的 SMJobBless 與舊式 login items),到 2026 年已是註冊背景服務與開機項目的標準做法。它的本質還是 launchd——plist 仍在,只是改成打包進 App bundle 裡,由系統代為註冊與管理,使用者也能在「系統設定 → 一般 → 登入項目」直接看到並關掉。對單純自己用的腳本,手寫 plist 丟進 ~/Library/LaunchAgents/ 仍然完全可行;但如果是要散佈給別人的 App,2026 年的正解是 SMAppService。

唯讀系統卷與 cryptexes。從 macOS Big Sur 的 Signed System Volume 開始,/System/Library/LaunchDaemons/ 這類路徑位於加密簽章的唯讀卷上,無法手動塞東西進去;Apple 自家的部分系統元件還會透過 cryptex(可加密掛載的系統擴充)在開機時動態疊上來。實務上的結論很簡單:自訂服務一律放 /Library/...(沒有 System)或家目錄底下的對應目錄,/System/... 是 Apple 的地盤、碰不得也不該碰。

舊語法持續被冷處理。launchctl load/unload 到 2026 年仍可用,沒有被移除,但 Apple 文件與 man 已全面改用 bootstrap/bootout 描述,新功能(如精細的 domain target)也只在新語法上。換句話說舊語法不會馬上壞,但新寫的東西沒有理由再用舊的。

動手做:一個每天定時跑的 LaunchAgent

把前面的概念串起來,做一個實際的例子:每天凌晨 3 點 30 分跑一次備份腳本,跑失敗才重試,輸出寫到 log 方便事後查。先寫 plist,存成 ~/Library/LaunchAgents/tw.klab.backup.plist

<?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>tw.klab.backup</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Users/kyle/bin/backup.sh</string>
    </array>

    <!-- 每天 03:30 觸發;睡眠中錯過會在喚醒後補跑 -->
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>3</integer>
        <key>Minute</key>
        <integer>30</integer>
    </dict>

    <!-- 只有失敗(非 0 退出)才重啟,正常跑完就放它結束 -->
    <key>KeepAlive</key>
    <dict>
        <key>SuccessfulExit</key>
        <false/>
    </dict>

    <!-- 把輸出導去 log,除錯靠這兩行 -->
    <key>StandardOutPath</key>
    <string>/Users/kyle/Library/Logs/klab-backup.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/kyle/Library/Logs/klab-backup.err.log</string>
</dict>
</plist>

接著載入並驗證:

# plist 語法檢查,有錯會直接指出哪一行
plutil -lint ~/Library/LaunchAgents/tw.klab.backup.plist

# 載入到自己的 gui domain(501 換成 id -u 的結果)
launchctl bootstrap gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist

# 不等到凌晨,先手動踹一次確認腳本本身沒問題
launchctl kickstart -k gui/501/tw.klab.backup

# 看狀態與上次 exit code
launchctl print gui/501/tw.klab.backup | grep -E "state|last exit"

# 確認沒問題後,看 log 驗證輸出
tail -f ~/Library/Logs/klab-backup.log

如果 kickstart 後服務沒動靜,依序檢查三件事:plutil -lint 有沒有過、backup.sh 有沒有 chmod +x 且第一行 shebang 正確、以及 .err.log 裡是不是有「command not found」——後者幾乎都是前面提過的 PATH 問題,在腳本開頭補一行 export PATH=/usr/local/bin:/usr/bin:/bin 之類的就解決了。

該用哪一種,以及什麼時候不該用 launchd

把判斷收斂成幾條實用建議。要散佈給別人的 App 背景服務,2026 年直接用 SMAppService,別自己手動丟 plist。需要在沒人登入時就運作的系統服務,用 /Library/LaunchDaemons/ 下的 LaunchDaemon。只有自己這台機器要定時跑的腳本,手寫一個 plist 丟進 ~/Library/LaunchAgents/ 最簡單直接,沒必要為它包一個 App——之前寫的 macOS CapsLock 延遲解法 就是這種用法的實例。

反過來說,也有不該動用 launchd 的情況。如果只是想「現在跑一個一次性的長時間任務」,caffeinate 加一個背景行程就夠了,寫 plist 反而是過度設計。如果排程邏輯很單純、又只是個人臨時用途,crontab 在 macOS 其實還沒被拿掉,老實用也沒什麼不可以——只是要清楚它在睡眠時會漏跑,這個限制能接受就行。launchd 的價值在「需要被系統穩定託管、有條件式啟動或重啟需求」的場景;任務夠簡單時,硬套一份 plist 只是徒增維護成本。把工具用在它真正擅長的地方,比什麼都套 launchd 更實際。


Sponsored Links