上一篇 把 Google Sheets 資料層建立完了。這篇處理一個更實際的問題:Bot 運行在 24 小時不關機的伺服器上,崩潰怎麼辦?
還有就是本機開發和正式環境完全不一樣這件事,要怎麼用 Docker 把這個問題解決。
問題:Node.js 一遇到未捕獲的例外就直接掛掉
Node.js 有兩種「沒人處理的錯誤」情境:
unhandledRejection— Promise 被 reject 了,但沒有.catch()或try/catch處理uncaughtException— throw 出去的 Error 在呼叫鏈上都沒被攔截
預設行為是直接 crash。對一般 API server 來說,PM2 或 Docker 的 --restart unless-stopped 可以應付。但 discord.js 的 Bot 有狀態(已載入的快取、設定值等),單純讓 process 重啟還不夠,需要重新初始化 Discord Client 並重新登入。
解法:restartBot() + MAX_RESTART_ATTEMPTS
let restartAttempts = 0;
const MAX_RESTART_ATTEMPTS = 5;
function restartBot() {
if (restartAttempts >= MAX_RESTART_ATTEMPTS) {
console.error("已達到最大重啟次數,退出程式...");
process.exit(1); // 讓 Docker restart policy 接手
}
restartAttempts++;
console.log(`嘗試重新啟動 Bot...(第 ${restartAttempts} 次)`);
client.destroy().then(() => {
setTimeout(() => {
initBot();
client.login(token).catch((error) => {
console.error("重新連線失敗:", error);
process.exit(1);
});
}, 5000); // 等 5 秒再重試,避免 Discord rate limit
});
}設計邏輯:
- 每次呼叫
restartBot()計數 +1 - 達到 5 次上限後,改讓
process.exit(1)結束進程,交給 Docker--restart unless-stopped的機制做外部重啟 client.destroy()先把現有連線關乾淨,等 5 秒後重新執行initBot()和client.login()
攔截點在 initBot() 最開頭:
function initBot() {
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
restartBot();
});
process.on("uncaughtException", (err) => {
console.error("Uncaught Exception thrown:", err);
restartBot();
});
client = new Client({ ... });
// ...
}process.on 放在 initBot() 裡意味著每次重新初始化時都會重新掛上監聽器。這在某些情況會造成 listener 重複疊加,生產環境要注意。更穩健的做法是在 process.on 之前先 process.removeAllListeners()。
dev/prod 雙環境以 NODE_ENV 分離
本機開發和正式環境通常有幾個明顯差異:
| 本機開發 | 正式環境 | |
|---|---|---|
| Discord Bot Token | DEV_TOKEN | PROD_TOKEN |
| 啟動時讀取外部設定 | ❌ | ✅ |
| 啟動排程任務 | ❌ | ✅ |
這些差異全部靠一個布林值控制:
const isProduction = process.env.NODE_ENV === "production";
const token = isProduction ? process.env.PROD_TOKEN : process.env.DEV_TOKEN;在 client.once("ready") 裡:
if (isProduction) {
await loadExternalConfig(); // 從外部來源讀取設定
await registerSlashCommands();
scheduleRecurringTasks(client); // 啟動排程任務
} else {
// 開發模式只註冊基本指令,不做其他事
await registerSlashCommands();
}開發時只需要 node index.js,不用設 NODE_ENV,預設就走開發路徑,不會觸發外部 API call、不會啟動排程,速度快也不會影響正式資料。
Docker 部署
Dockerfile
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
ENV TZ=Asia/Taipei
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY . .
CMD ["node", "index.js"]時區是一個容易踩的坑。如果 Bot 有排程任務(例如每天早上 8 點發公告),或需要判斷「今天」的日期,container 預設是 UTC 時區,所有時間計算都會偏移 8 小時。
ENV TZ=Asia/Taipei
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone這兩行確保 container 裡的 new Date() 以台灣時間為基準。
.dockerignore
node_modules
key/
.env
.env.*
docker-compose.yml
README.md
key/ 是放 GCP Service Account JSON 的目錄,絕對不能進 image。它被 .dockerignore 排除,改用 -v ./key:/app/key volume mount 掛進 container:
"build": "docker build -t discord-bot .",
"deploy": "npm run build && docker run -d \
--name discordbot \
--restart unless-stopped \
--env-file .env \
-e NODE_ENV=production \
-v ./key:/app/key \
discord-bot",
"redeploy": "docker rm -f discordbot & npm run deploy"部署三步驟:
npm run build— build imagenpm run deploy— run container,掛.env和key/volume- 要更新時:
npm run redeploy— 強制移除舊 container 再重新 build + run
整體啟動到穩定運行的完整路徑
docker run
└── node index.js
└── initBot()
├── process.on("unhandledRejection") → restartBot()
├── process.on("uncaughtException") → restartBot()
├── new Client({ intents: [...] })
└── client.login(token)
└── client.once("ready")
├── [production] 讀取外部設定
├── guild.commands.set(COMMANDS)
└── [production] 啟動排程任務
崩潰時:
unhandledRejection / uncaughtException
└── restartBot()
├── restartAttempts < MAX(5) → client.destroy() → setTimeout 5s → initBot()
└── restartAttempts >= MAX(5) → process.exit(1) → Docker restart policy
這七篇把 Discord Bot 的核心都講完了:從申請設定、Slash Commands、按鈕、下拉選單、Embed 美化、Google Sheets 資料層,到最後的 Docker 部署與自動重啟機制。