探求の始まり(「なぜ」)
私たちのサイドプロジェクトが静かな週末の趣味から、実際に人々が利用するものへと変わった初めての瞬間を覚えています。私たちはユーザーのスコアをインメモリのMapに保存する小さなNode.js APIを立ち上げました。生活は快適でした — ある人気ストリーマーが配信中に私たちのURLを叫ぶまで。そしてトラフィックは1秒あたり10リクエストから2,000リクエストに跳ね上がりました。突然、単一のEC2インスタンスが死にかけたロボットのような音を立て始めました。CPUは99%に達し、レイテンシは数秒に急上昇し、ログには「RHEL: out of memory」というメッセージが溢れました。私はコンストラクト内のネオのように感じ、実際にはHTTP 503の弾丸をかわしながら、サーバーのグリッチを止める秘密のチートコードがあるのかと疑問に思いました。
その瞬間、私に問いかけさせました:世界が私たちに気づいたとき、私たちのアプリはどうやって生き残るのか? 答えは単に「より大きなボックスを買う」ことではありませんでした。それはスケーリング自体を再考することでした。
啓示(洞察)
スケーリングは万能の強化ではありません。主に2つの種類があります:
垂直スケーリング(スケールアップ) – 同じマシンにより多くのCPU、RAM、またはSSDを投入します。RPGでキャラクターのステータスをアップグレードするようなものです。シンプルです:インスタンスを停止し、リサイズし、再起動します。利点は?コード変更不要、即座にブースト。欠点は?ハードな上限にぶつかる — クラウドプロバイダーはそれほど大きくなく、コストは指数関数的に上昇し、それでも単一障害点があります。そのマシンが死んだら、ゲーム全体が終了します。
水平スケーリング(スケールアウト) – ロードバランサーの後ろに同一のインスタンスを追加します。各ノードは同じコードを実行し、何も共有しない(またはRedisやDBのような外部ストア経由でのみ共有する)です。まるでネオをクローンして、トラフィックのエージェントと戦う「選ばれし者」のチームを作るようなものです。必要に応じてノードを追加・削除でき、ノード障害に耐え、しばしば1台の巨大マシンではなく多くの控えめなマシンを使用するため、同じスループットに対してコストを抑えられます。
私たちのアプリが最悪な方法でステートフルであることに気づいたとき、本当の洞察が訪れました。ユーザーのセッションをメモリに保持し、データをローカルにキャッシュし、一時ファイルをインスタンスのディスクに書き込んでいました。これにより水平スケーリングは不可能になりました — 新しいノードは独自の分離されたメモリを持ち、ロードバランサーがユーザーを別の場所に送った瞬間にログアウトされてしまうのです。啓示?アプリをステートレスにし、状態を外部サービスにプッシュすれば、ボスのようにスケールアウトできる。
力を使いこなす(コードと例)
苦闘:ステートフルな単一ノードサーバー
// 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に存在 → 再起動時や新しいインスタンス開始時に失われる。 - 外部DBやキャッシュがない → 状態を共有できない。
- ヘルスチェックなし、SIGTERMハンドリングなし → Kubernetesはポッドを不適切に終了する。
- ハードコードされたポートにより、ロードバランサーの後ろで複数のコピーを実行することが困難。
勝利:ステートレスで水平スケーラブルなサービス
// 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など)がリクエストをドロップせずにポッドを置き換えられる。
- 環境経由の設定 – ポート、Redis URLなどは環境から来るため、同じDockerイメージがどこでも実行可能。
避けるべき一般的な罠(「ボス戦」の落とし穴):
- スティッキーセッション – IPベースのアフィニティに依存しないこと;それは水平スケーリングの目的を台無しにし、不均等な負荷を生み出す。
- ローカルファイル書き込み – アプリがアップロードやログをディスクに書き込む場合、共有ボリューム(S3、EFS、または専用ストレージサービス)を使用するか、中央ログシステムにストリーミングする。
- 可観測性の無視 – リクエストID、構造化ログ、メトリクス(Prometheus)を早期に追加する;そうでなければ盲目でのスケーリングはヘルスバーのないボスとの戦いのようなものになる。
この新しい力が重要な理由
サービスをステートレスにし、状態をRedisに押し込んだ後、マジックが起こりました。私たちは単純なHorizontalPodAutoscalerを持つKubernetesクラスタに同じDockerイメージをデプロイし、CPUを監視しました。ストリーマーの視聴者が急増したとき、オートスケーラーは数秒で追加のポッドを起動し、ロードバランサーはトラフィックを分散し、レイテンシは100 ms未満に保たれました。単一のボックスのリサイズのための深夜のSSHコールに慌てる必要はなく、私たちのインフラストラクチャは今や需要に反応するようになりました。
水平スケーリングにより、ダウンタイムゼロのデプロイも可能になりました:新しいバージョンをロールアウトし、LBは徐々にトラフィックを新しいポッドに移し、古いポッドは進行中のリクエストを完了後に終了します。ついにマトリックスのコードを見ることができたような感覚でした — 緑のグリフが流れ、各リクエストがシステムをどのように移動するかを正確に理解しました。
最も重要なのは、コスト曲線が平坦化したことです。90%の時間アイドル状態の過剰プロビジョニングされた獣に支払う代わりに、実際の負荷を処理するために必要なだけのコンピュートに支払い、静かな期間にはスケールダウンしました。これは食料品店に行くためだけに運転するスポーツカーを買うことと、必要なときに現れる効率的な乗り物のフリートを持つことの違いです。
あなたの番:探求に出発する
あなた自身のアプリをレベルアップするための課題です:単一のVMまたはベアメタルで実行している小さなサービスを取り、それをコンテナ化し、イメージをレジストリにプッシュし、無料ティアのKubernetesオファリング(Google CloudのAutopilotやAzureのAKS with burstable nodesなど)にデプロイしてください。 Redisインスタンス(管理型またはローカルテスト用のシンプルなDocker-compose)を追加し、セッションやキャッシュ状態をプロセスから移動させます。次にheyやk6のようなツールで叩き、オートスケーラーが動作するのを見てください。
新しいポッドが表示され、レイテンシが平坦に保たれているのを見たとき、あなたはレベルアップしたことを知るでしょう — ちょうどネオがようやく弾丸をかわし、世界が実際に何であるかを見るように。ハッピースケーリング! 🚀
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.