任務開始(「為什麼」)
我記得我們把這個 side-project 從一個安靜的週末興趣愛好,變成真正有人在使用的那一刻。我們推出了一個微小的 Node.js API,它把使用者分數存放在記憶體中的 Map。日子過得很美好——直到一位知名實況主在直播中喊出我們的 URL,流量從每秒 10 個請求暴增到 2,000 個。突然間,我們單一的 EC2 執行個體聽起來就像一台快壞掉的機器:CPU 飆到 99%,延遲飆升到數秒,記錄檔裡滿是「RHEL: out of memory」的訊息。我感覺自己就像《駭客任務》裡的尼歐,正在閃避那些其實是 HTTP 503 的子彈,心想是不是有什麼祕密作弊碼可以讓伺服器停止出錯。
那一刻逼使我問自己:當全世界突然注意到我們時,我們要怎麼讓應用程式活下去?答案不只是「買一台更大的機器」。而是要重新思考擴展的本質。
啟示(洞察)
擴展不是一體適用的升級。有兩種主要形式:
垂直擴展(scale-up) —— 給同一台機器增加更多 CPU、RAM 或 SSD。這就像在 RPG 裡升級角色的能力值。很簡單:停止執行個體、調整大小、再啟動。優點?不用改程式碼、立即提升效能。缺點?會碰到硬性天花板——雲端供應商只提供到一定大小,成本呈指數上升,而且仍然是單點故障。如果那台機器掛掉,你的整個遊戲就結束了。
水平擴展(scale-out) —— 在負載平衡器後面新增更多相同的執行個體。每個節點執行相同的程式碼,不共享任何東西(或只透過 Redis、資料庫等外部儲存共享)。這就像複製尼歐,讓你擁有一整支「天命之人」團隊來對抗流量代理。你可以依需求新增或移除節點,承受節點故障,而且通常用多台中階機器比用一台超級機器更划算。
當我意識到我們的應用程式有最糟糕的「有狀態」問題時,真正的洞察才出現:它把使用者工作階段存在記憶體、把資料快取在本地,甚至把暫存檔寫到執行個體的磁碟上。這讓水平擴展變得不可能——任何新節點都會有自己隔離的記憶體,使用者只要被負載平衡器導到別台機器,就會被登出。啟示是:讓應用程式變成無狀態,把狀態推到外部服務,這樣你就能像老闆一樣水平擴展。
掌握力量(程式碼與範例)
掙扎:有狀態的單節點伺服器
// server.js – before (the painful version)
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// ❌ In‑memory session store – dies with the process
const sessions = new Map();
app.use(express.json());
app.post('/login', (req, res) => {
const { username, password } = req.body;
// fake auth
if (username === 'admin' && password === 'secret') {
const token = Math.random().toString(36).substring(2, 15);
sessions.set(token, { username });
return res.json({ token });
}
res.status(401).send('Bad credentials');
});
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const user = sessions.get(token);
if (!user) return res.status(401).send('Unauthenticated');
res.json(user);
});
// ❌ Listening on a fixed port – no graceful shutdown handling
app.listen(PORT, () => console.log(`🚀 Server listening on ${PORT}`));
Enter fullscreen mode Exit fullscreen mode
問題在哪?
- 工作階段存在
sessionsMap 裡 → 重新啟動或新執行個體啟動時就會遺失。 - 沒有外部資料庫或快取 → 無法共享狀態。
- 沒有健康檢查、沒有 SIGTERM 處理 → Kubernetes 會直接把 Pod 殺掉。
- 硬編碼的連接埠讓你在負載平衡器後面跑多個複本變得很麻煩。
勝利:無狀態、可水平擴展的服務
// server.js – after (the scalable version)
const express = require('express');
const redis = require('redis');
const app = express();
const PORT = process.env.PORT || 3000;
// ✅ External session store – Redis (or any shared DB)
const redisClient = redis.createClient({
url: process.env.REDIS_URL // e.g. redis://redis:6379
});
redisClient.on('error', err => console.error('Redis error:', err));
redisClient.connect();
app.use(express.json());
// Helper to store/retrieve sessions from Redis
async function setSession(token, data) {
await redisClient.set(`sess:${token}`, JSON.stringify(data), { EX: 60 * 60 }); // 1h TTL
}
async function getSession(token) {
const raw = await redisClient.get(`sess:${token}`);
return raw ? JSON.parse(raw) : null;
}
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (username === 'admin' && password === 'secret') {
const token = Math.random().toString(36).substring(2, 15);
await setSession(token, { username, loggedInAt: Date.now() });
return res.json({ token });
}
res.status(401).send('Bad credentials');
});
app.get('/profile', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const user = await getSession(token);
if (!user) return res.status(401).send('Unauthenticated');
res.json(user);
});
// ✅ Graceful shutdown – lets LB drain connections
function shutdown() {
console.log('🛑 Received shutdown signal, closing Redis…');
redisClient.quit().then(() => process.exit(0));
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
app.listen(PORT, () => console.log(`🚀 Server listening on ${PORT}`));
Enter fullscreen mode Exit fullscreen mode
改變了什麼?
- 外部狀態 —— 工作階段存在 Redis,可供任何執行個體存取。
- 無狀態程式碼 —— 伺服器本身不保留任何黏性資料;你可以啟動 1、10 或 100 個複本。
- 健康檢查與關機 —— 回應 SIGTERM/SIGINT,讓編排工具(K8s、ECS 等)在不中斷請求的情況下替換 Pod。
- 環境變數設定 —— 連接埠、Redis URL 等都來自環境變數,讓同一張 Docker 映像檔可以在任何地方執行。
要避免的常見陷阱(「Boss 戰」地雷):
- 黏性工作階段 —— 不要依賴基於 IP 的親和性;這會破壞水平擴展的意義,並造成負載不均。
- 本地檔案寫入 —— 如果你的應用程式把上傳檔或日誌寫到磁碟,請使用共享儲存(S3、EFS 或專用儲存服務),或把日誌串流到集中式日誌系統。
- 忽略可觀測性 —— 盡早加入請求 ID、結構化日誌與指標(Prometheus);否則在盲目擴展時會像在打 Boss 卻沒有血條一樣。
為什麼這股新力量很重要
當我們把服務改成無狀態,並把狀態推到 Redis 後,魔法發生了。我們把同一張 Docker 映像檔部署到 Kubernetes 叢集,並搭配簡單的 HorizontalPodAutoscaler 來監看 CPU。當實況主的觀眾湧入時,自動擴展器在幾秒內就啟動額外的 Pod,負載平衡器分散流量,延遲維持在 100 ms 以下。不再需要半夜慌張地 SSH 進去調整單一機器——我們的基礎架構現在可以「回應」需求。
水平擴展也讓我們能做到零停機部署:推出新版本時,負載平衡器會逐步把流量導到新 Pod,舊 Pod 在處理完進行中的請求後才終止。這感覺就像終於看見《駭客任務》的程式碼——綠色符號流動,清楚知道每個請求在系統中如何移動。
最重要的是,成本曲線變得平緩。我們不再支付一台 90% 時間都閒置的過度配置巨獸,而是只為實際負載付費,閒置時就縮容。這就像買一輛只開去雜貨店的跑車,與擁有一支需要時才出現的高效車隊之間的差別。
輪到你了:踏上旅程
這裡給你一個挑戰,讓你的應用程式升級:把你目前跑在單一 VM 或裸機上的小型服務容器化,把映像檔推送到 Registry,然後部署到免費等級的 Kubernetes 服務(例如 Google Cloud 的 Autopilot 或 Azure 的 AKS 搭配 burstable 節點)。加入 Redis 執行個體(受控或用 Docker-compose 做本地測試),把任何工作階段或快取狀態移出程序。然後用 hey 或 k6 之類的工具猛壓它,看看自動擴展器是否啟動。
當你看到新的 Pod 出現,延遲保持平穩,你就知道自己升級了——就像尼歐終於閃過那些子彈,看清世界的真相。祝擴展愉快!🚀
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.