應用層排程放進服務行程、系統層工作放宿主機。為什麼容器裡不該裝 cron、開關要用環境變數、以及排程沒跑跟排程跑完沒事看起來一模一樣。
發布於 2026-09-06・約 4 分鐘
「每天凌晨四點跑一次」這種需求,在容器化的環境裡放哪一層意外地不好決定。我試過四種,最後定在兩種,順便記一個查錯機器的蠢事。
有一天排程沒跑。我進開發用的容器查 crontab:
$ docker exec homelab-codeman crontab -l no crontab for root
「沒有排程」——花了半小時才想起來,排程根本不在那個容器裡。它是跑排程的那個服務容器裡的 node-cron,跟 crontab 一點關係都沒有。
node-cron
查排程之前先確認你在查哪一台。 這聽起來很基本,但在有七八個容器的環境裡,「進容器查一下」的直覺會直接把你帶到最常用的那一台,而不是正確的那一台。
一、容器裡裝 cron 或 supervisord。 不推薦。你得讓容器同時跑兩個行程,而 Docker 的模型是一個容器一個主行程。日誌會亂、訊號處理會亂、cron 拿不到你在 compose 裡設的環境變數(它有自己的環境),排程失敗也不會讓容器變成不健康。
二、宿主機的 cron 呼叫 docker exec。 可以用,適合系統層的工作。缺點是排程定義跟服務定義分開放,搬機器時很容易漏掉。
docker exec
三、獨立的排程容器。 乾淨,但為了一天跑一次的工作多養一個容器,對小規模的自架環境是過度設計。
四、把排程做進服務行程裡。 我大部分用這個。
服務本來就是長跑的,讓它自己管自己的排程最單純:
const cron = require('node-cron') if (process.env.GATHER_SCHEDULER === 'on') { // 每天 04:00(凌晨低峰)跑一次 cron.schedule('0 4 * * *', runScheduledScrape, { timezone: 'Asia/Taipei' }) console.log('⏰ 自動排程已開啟:每天 04:00 (Asia/Taipei) 執行一輪蒐集') }
三個地方是刻意的:
時區一定要明寫。 容器預設是 UTC,0 4 * * * 會變成台灣時間中午 12 點。這個錯誤不會報錯,只會讓你的「凌晨低峰跑」變成「尖峰時段跑」,而且要等到第二天才發現。
0 4 * * *
開關用環境變數。 開發時關掉、正式環境開著,同一份程式碼。而且改開關是改 .env,不是改程式碼。
.env
啟動時印一行。 這一行是你日後唯一能確認「排程真的掛上去了」的證據。沒有它,GATHER_SCHEDULER 打錯字的話你什麼都看不到。
GATHER_SCHEDULER
環境變數要記得:改 .env 之後 docker restart 沒用,環境變數在建容器那一刻就固定了,要 docker compose up -d 重建。改程式碼才是 restart。
docker restart
docker compose up -d
備份、映像檔清理、drift 檢查這類「跟某個服務無關、或者要在服務掛掉時仍然運作」的東西,放宿主機的 cron:
30 3 * * * docker exec homelab-codeman bash /workspace/homelab/scripts/backup.sh
判準是這個工作在服務掛掉的時候還該不該跑。備份該跑(服務掛了資料還在),抓資料不該跑(服務都掛了抓來也沒地方放)。
這個坑很容易中。手動觸發一個要跑很久的抓取:
$ docker exec homelab-gather node collect.js # ❌
你的終端機斷線、SSH 逾時、或者你關掉視窗,那個行程就跟著死了。跑了四十分鐘的工作在第三十九分鐘沒了。
$ docker exec -d homelab-gather node collect.js # ✅ 背景跑
-d 讓它脫離你的終端機。輸出去哪裡要自己安排(寫檔或走容器日誌)。
-d
這是排程最根本的問題。
一個健康的 cron 系統,九成時間是靜止的。沒有輸出、沒有通知、沒有任何動靜。而一個死掉的 cron 系統,也是沒有輸出、沒有通知、沒有任何動靜。
兩者從外面看完全一樣。
所以排程一定要往外寫一筆「我跑完了」的時間戳,然後由別的地方判斷「上次跑完到現在多久」。我的備份腳本門檻設 30 小時(日備一次留點餘裕),超過就代表排程死了。
只發失敗通知是對的(成功也發的話很快會被當雜訊忽略),但那樣就完全需要這個時間戳,否則「沒有消息」是個歧義。
同樣的東西也適合放在服務的健康端點裡:
app.get('/healthz/scheduler', (req, res) => { res.json({ enabled: process.env.GATHER_SCHEDULER === 'on', lastRunAt: state.lastRunAt, // 上次真的跑完的時間 nextRunAt: state.nextRunAt, // 下次預定時間 lastResult: state.lastResult, // ok / error + 訊息 }) })
enabled 那一欄特別有用,因為排程沒開跟排程壞掉是兩件完全不同的事,而且處置相反。
enabled
排程屬於某個服務的業務邏輯 → 放進那個服務的行程,用環境變數控制。
排程是系統維運、或要在服務掛掉時仍運作 → 放宿主機的 cron。
兩種都要往外寫一筆完成時間戳,不然你只會在需要它的那天發現它已經死了三個月。
標籤:Docker、cron、排程、Node.js、自架服務