利用 systemd 進行長駐 Worker 的生命週期管理

Roga Lin

2026-08-28

前言

我在一開始需要跑背景工作時,常常會先寫一個啟動 script,然後在裡面起若干個 background process,然後交給 cron 定時呼叫。

這個方法的好處是:不用新增基礎設施、不用大幅改造系統也能馬上提高平行處理量。不過當系統量體變大,或是業務邏輯變複雜的時候 (例如:要存取外部服務,或會因記憶體、網路與資料庫錯誤而結束..等等) 就會開始需要被更好的管理或是監控。

原始設計:由啟動 script 一次開多個 worker

原本的設計大致如下:cron 定期執行一個啟動 script,script 依序建立固定數量的 worker process,然後自己結束。

概念上的虛擬碼:

for i in 1..5:
    background start worker
    wait a few seconds

worker 取得一筆待處理資料、執行需要的工作、寫回結果,然後回到迴圈繼續處理下一筆。每隻 worker 都是獨立 process,所以只要等待處理的工作認領有正確的原子條件,多隻 worker 可以安全地同時處理不同工作。

這種架構的優點是:

但看似簡單的背後,背後也有一些衍生的問題。

第一個問題:排程頻率不等於 worker 的生命週期

假設 cron 每五分鐘呼叫一次啟動 script,但每隻 worker 可能運作好幾小時。

10:00  啟動第一批 5 隻 worker
10:05  啟動第二批 5 隻 worker
10:10  啟動第三批 5 隻 worker
10:15  啟動第四批 5 隻 worker
...

如果每批 worker 都還沒有結束,process 數量會持續疊加。這種狀況通常不會在一開始立刻爆炸,而是隨時間慢慢讓 CPU、記憶體、資料庫連線、Disk I/O 與外部網路請求一起升高。

只加 lock 還不夠

面對重複啟動,第一個想到的修正通常是 lock:啟動前先取得 lock,取得失敗就表示已經有一批 worker 在跑,本次直接結束。

這確實能避免 process overlap,但有個容易忽略的細節:lock 必須持有到所有 worker 都結束。

若 script 只是啟動 background process 後立刻離開,lock 也會立刻釋放。下一次 cron 依然能成功取得 lock,結果還是會產生重複的 worker。

取得 lock
啟動 background worker
script 結束,lock 釋放   # 太早了

要正確使用這種方式,launcher 必須等待所有 child process 結束後才離開。這對「會很快完成的 batch」還算合理;但如果 worker 本來就應該長駐,launcher 也會變成一個需要長期存活、負責等待與回收子程序的 supervisor。

當情況複雜到這邊的時候,我建議不要再往下開發監控相關的空能,因為繼續做下去其實已經開始在重新實作作業系統服務管理器該做的事情了。

第二個問題:worker 死掉後,沒有人補回來

background process 的 parent script 通常在啟動後已經結束。因此 worker 發生例外情況 (未捕捉的例外, OOM, 系統重開機…等等),這個 process 就會消失。然後運作中的 worker (process) 數量會逐步下降。

把「worker 死掉時自動重啟」寫到一個 launcher 來管理也不是不行,但必須處理不少邊界情況:如何判斷 child 是否已結束、如何避免 zombie process、多久後重啟、連續失敗幾次要停止、收到停止訊號時如何清理,以及主機重開後如何恢復。

但這些都是成熟 process supervisor 已經提供的能力。

為什麼選 systemd

在由 systemd 管理的 Linux 主機上,systemd 本來就負責 service 的啟動、停止、失敗重啟與開機自動啟動。對長駐 worker 來說,讓 systemd 直接啟動 worker,比讓 cron 間接啟動一個 shell launcher 更符合責任分工:

原本:cron -> launcher -> 多個 worker

改後:systemd -> worker instance

這裡的重點是「一個 worker 對應一個 service instance」,而不是讓 systemd 只監督一個再去建立多個 child process 的 launcher。

worker@1.service  -> worker process 1
worker@2.service  -> worker process 2
worker@3.service  -> worker process 3

若第二隻 worker 非預期結束,systemd 只需要重啟 worker@2;其他 worker 繼續工作,不會因為單一失敗而整批重啟。

systemd template service 的概念

systemd 的 template unit 可以用 @ 建立多個 instance。例如 worker@.service 是範本,而 worker@1.serviceworker@2.service 是不同 instance。

以下是設定範例:

[Unit]
Description=Background worker %i
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=5min
StartLimitBurst=5

[Service]
Type=simple
User=service-user
Group=service-group
WorkingDirectory=/srv/application
ExecStart=/usr/local/bin/worker-command
Restart=on-failure
RestartSec=30s
MemoryMax=256M
KillSignal=SIGTERM
TimeoutStopSec=45s

[Install]
WantedBy=multi-user.target

幾個重要設定如下:

MemoryMax 不是越小越好。應先以較保守、能讓正常工作完成的值開始,再根據實際 RSS、尖峰記憶體與主機可用容量調整。若系統同時開 N 隻 worker,至少要預留 N 倍上限外加資料庫、Web server、OS page cache 等其他資源。

worker 數量怎麼調整

這個設計不再由一個 script 裡的固定迴圈決定 worker 數,而是由啟動多少個 instance 決定。

啟動 1 個 instance  -> 1 隻 worker
啟動 3 個 instance  -> 3 隻 worker
啟動 5 個 instance  -> 5 隻 worker

實務上不建議一開始就開很多隻。背景工作常涉及資料庫更新、外部 API、網頁擷取或檔案處理;worker 數量增加,不代表吞吐量會線性成長,反而可能先碰到連線數、rate limit、CPU 或 I/O 的瓶頸。

比較保守的流程是:

  1. 先啟動一隻 worker。
  2. 觀察 CPU、記憶體、Disk I/O、資料庫負載、完成速率與失敗率。
  3. 確認系統仍有餘裕後,再增加一隻 instance。
  4. 每次調整後繼續觀察,而不是直接把併發數調到最大。

停止 cron,而不是和 systemd 並存

切換到 systemd 後,原本的 cron 就應該被儘快移除,如果 systemd 與舊 launcher 同時存在,會出現兩套不同的生命週期管理方式:一部分 worker 受 systemd 重啟與記憶體限制保護,另一部分則可能再次產生 process overlap。這不只讓監控結果難以判讀,也會我們無法確切得知「目前到底有幾隻 worker」。

這個改善的限制

改用 systemd 解決的是 process 的生命週期,但不會自動修正 worker 的業務邏輯。

例如,若 worker 在認領一筆工作後主機斷電,資料可能永久停留在「處理中」狀態。systemd 能重新啟動 worker,但無法判斷那筆工作是否應重新處理。這仍需要佇列本身有逾時認領、重試次數或人工處理機制。

同樣地,systemd 也不會讓慢查詢、自身的記憶體洩漏、外部服務 rate limit 或錯誤的平行認領邏輯自動消失。它的價值是把「process 意外結束後要怎麼辦」變成穩定且可觀察的基礎設施行為,讓應用程式能專注在工作本身。

小結

一開始用 background script 啟動固定數量的 worker 很合理,特別是短時間 batch job。但當 worker 變成長駐服務,繼續用 cron 週期性建立 process,容易把排程頻率誤當成生命週期管理,最後累積出 process overlap、資源尖峰與難以補回的 worker 缺口。

改用 systemd template instance 後,幾個原本分散的問題會有比較清楚的歸屬:

  1. worker 的數量由 service instance 決定。
  2. worker 異常結束時,由 systemd 個別重啟。
  3. 記憶體上限、停止行為與重啟頻率由 service 設定管理。
  4. 日誌與狀態可透過同一個 service 管理介面查看。
  5. cron 不再參與長駐 worker 的啟動。

對這類長時間背景工作而言,把 application worker 當成正式 service 管理,通常比在 script 裡持續補上 lock、PID 檢查與重啟迴圈更簡單,也更容易在下一次問題發生時找出原因。