Docker ComposeでMongoDB Sharded Clusterを構築する:大規模アプリのデータシャーディング

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

MongoDB Shardingが本当に必要になるとき

約8ヶ月前、MongoDB single instanceでe-commerceシステムをメンテナンスしていました。データセットが50GBを超え、concurrent usersが3000人以上に増えるまでは問題なく動いていました。リアルタイムクエリが徐々に遅くなり、50msから2〜3秒まで悪化しました。Vertical scaling(RAM・CPU増設)で数週間しのいでも、またすぐにボトルネックに逆戻りです。

そこで本格的にMongoDB Sharded Clusterに取り組み始めました。テスト環境にはDocker Composeが最速の手段です。10コンテナ全体を約2分でスピンアップでき、設定を間違えてもきれいにリセットできて、追加のサーバー費用も不要です。

3つのコンポーネントで構成されるアーキテクチャ

  • Config Servers:クラスターのメタデータを保存します — どのシャードがどのデータレンジを持つかを管理。3ノードのreplica setとして動作します。
  • Shard Servers:実際にデータを保存する場所。各シャードはHAを確保するために3ノードのreplica setで構成されます。
  • mongos(Query Router):唯一のゲートウェイです。クライアントはここに接続し、mongosが自動的に適切なシャードへクエリをルーティングします。

環境のセットアップ

最小要件

  • Docker Engine 24以上 および Docker Compose v2
  • 最低4GB RAM(実際のテストには8GB推奨)
  • MongoDB 7.0

プロジェクトディレクトリとkeyfileを作成します — replica set内のMongoDBインスタンス同士が相互認証するために必須です:

mkdir mongo-sharded && cd mongo-sharded
mkdir -p config/keyfile scripts

openssl rand -base64 756 > config/keyfile/mongo-keyfile
chmod 400 config/keyfile/mongo-keyfile

docker-compose.ymlファイル

クラスター全体の構成:3台のconfig server、6台のshard server(2シャード × 3ノード)、1台のmongos router。

version: '3.8'

networks:
  mongo-cluster:
    driver: bridge

x-mongo-common: &mongo-common
  image: mongo:7.0
  restart: unless-stopped
  networks:
    - mongo-cluster

services:
  # --- Config Servers ---
  configsvr1:
    <<: *mongo-common
    container_name: configsvr1
    command: mongod --configsvr --replSet configReplSet --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - configsvr1_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27119:27017"

  configsvr2:
    <<: *mongo-common
    container_name: configsvr2
    command: mongod --configsvr --replSet configReplSet --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - configsvr2_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  configsvr3:
    <<: *mongo-common
    container_name: configsvr3
    command: mongod --configsvr --replSet configReplSet --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - configsvr3_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  # --- Shard 1 ---
  shard1rs1:
    <<: *mongo-common
    container_name: shard1rs1
    command: mongod --shardsvr --replSet shard1ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard1rs1_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27121:27017"

  shard1rs2:
    <<: *mongo-common
    container_name: shard1rs2
    command: mongod --shardsvr --replSet shard1ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard1rs2_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  shard1rs3:
    <<: *mongo-common
    container_name: shard1rs3
    command: mongod --shardsvr --replSet shard1ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard1rs3_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  # --- Shard 2 ---
  shard2rs1:
    <<: *mongo-common
    container_name: shard2rs1
    command: mongod --shardsvr --replSet shard2ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard2rs1_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27122:27017"

  shard2rs2:
    <<: *mongo-common
    container_name: shard2rs2
    command: mongod --shardsvr --replSet shard2ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard2rs2_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  shard2rs3:
    <<: *mongo-common
    container_name: shard2rs3
    command: mongod --shardsvr --replSet shard2ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard2rs3_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  # --- Query Router ---
  mongos:
    <<: *mongo-common
    container_name: mongos
    command: mongos --configdb configReplSet/configsvr1:27017,configsvr2:27017,configsvr3:27017 --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27017:27017"
    depends_on:
      - configsvr1
      - configsvr2
      - configsvr3
    deploy:
      resources:
        limits:
          memory: 1G
        reservations:
          memory: 512M

volumes:
  configsvr1_data:
  configsvr2_data:
  configsvr3_data:
  shard1rs1_data:
  shard1rs2_data:
  shard1rs3_data:
  shard2rs1_data:
  shard2rs2_data:
  shard2rs3_data:

--wiredTigerCacheSizeGB 0.5フラグは実際の経験から得た教訓です。最初のデプロイ時はこれを省略していました — 制限が一切なかったのです。負荷テストを3時間ほど動かした後、mongosがホストのRAMをほぼ使い果たしてしまいました。原因を突き止めるのに2日かかりました。Dockerではメモリ制限は最初から宣言すべきものであり、障害が起きてから対処するものではありません。

起動後の詳細設定

クラスター全体を起動します:

docker compose up -d
# インスタンスが起動するまで約30秒待つ

Config Serverのreplica setを初期化します:

docker exec -it configsvr1 mongosh --port 27017 --eval '
rs.initiate({
  _id: "configReplSet",
  configsvr: true,
  members: [
    { _id: 0, host: "configsvr1:27017" },
    { _id: 1, host: "configsvr2:27017" },
    { _id: 2, host: "configsvr3:27017" }
  ]
})'

Shard 1とShard 2のreplica setを初期化します:

docker exec -it shard1rs1 mongosh --port 27017 --eval '
rs.initiate({
  _id: "shard1ReplSet",
  members: [
    { _id: 0, host: "shard1rs1:27017" },
    { _id: 1, host: "shard1rs2:27017" },
    { _id: 2, host: "shard1rs3:27017" }
  ]
})'

docker exec -it shard2rs1 mongosh --port 27017 --eval '
rs.initiate({
  _id: "shard2ReplSet",
  members: [
    { _id: 0, host: "shard2rs1:27017" },
    { _id: 1, host: "shard2rs2:27017" },
    { _id: 2, host: "shard2rs3:27017" }
  ]
})'

mongos経由でシャードをクラスターに登録します:

docker exec -it mongos mongosh --port 27017 --eval '
sh.addShard("shard1ReplSet/shard1rs1:27017,shard1rs2:27017,shard1rs3:27017");
sh.addShard("shard2ReplSet/shard2rs1:27017,shard2rs2:27017,shard2rs3:27017");'

データベースとコレクションのShardingを有効化する

Shardingは自動的に有効になりません — シャーディングするデータベースとコレクションを明示的に指定する必要があります。最も重要なステップはshard keyの選択です。この決定はほぼ後から変更できません。コレクションにデータが入った後でshard keyを変更するには、全データをダンプし、コレクションを削除して、最初からインポートし直す必要があります。

docker exec -it mongos mongosh --port 27017 --eval '
// データベースのshardingを有効化
sh.enableSharding("myapp");

// Hashed sharding: 均等に分散、特定IDでのクエリに適している
sh.shardCollection("myapp.orders", { user_id: "hashed" });

// Range-based: rangeクエリに効率的だが、hotspotが発生しやすい
// sh.shardCollection("myapp.logs", { timestamp: 1 });'

使い分けの目安:特定IDでクエリすることが多い場合(user_id: 42)はhashedを使い、範囲クエリ(日付Aから日付Bまでのtimestamp絞り込み)にはrange-basedを使います。e-commerceのordersコレクションではuser_idによるhashedシャーディングの方が均等に分散できます — 特定のユーザー層がトラフィックを独占することがありません。一方、logsコレクションはtimestampによるrange-basedの方が合理的で、直近7日間のログを取得するようなクエリが大半を占めるためです。

動作確認とモニタリング

クラスターのステータス確認

# クラスター概要とチャンクの分布を確認
docker exec -it mongos mongosh --port 27017 --eval 'sh.status()'

# 各シャードへのデータ分布の詳細を確認
docker exec -it mongos mongosh --port 27017 --eval '
use myapp;
db.orders.getShardDistribution()'

実際の分散をテストする

docker exec -it mongos mongosh --port 27017 --eval '
use myapp;
for (let i = 0; i < 10000; i++) {
  db.orders.insertOne({
    user_id: i,
    product: "item_" + Math.floor(Math.random() * 100),
    amount: Math.random() * 1000,
    created_at: new Date()
  });
}
print("Total:", db.orders.countDocuments());
db.orders.getShardDistribution();'

10,000件のドキュメントを挿入すると、getShardDistribution()でデータが2つのシャードにほぼ均等に分散されていることを確認できます。最初はクラスター全体で1チャンクしかありませんが、データが増えるにつれてMongoDBが自動的に分割・マイグレーションを行います。

explain()によるクエリ分析

docker exec -it mongos mongosh --port 27017 --eval '
use myapp;
db.orders.find({ user_id: 42 }).explain("executionStats")'

出力のqueryPlanner.winningPlan.shardsフィールドを確認します:シャードが1つだけ表示されている場合 — クエリはtargetedで、mongosがどのシャードに問い合わせるかを正確に把握しています。両方のシャードが表示されている場合 — scatter-gatherで、mongosが全シャードに問い合わせて結果をマージします。シャードが2つなら、オーバーヘッドはまだ問題になりません。しかし8〜10シャードに拡張した状態でqueryが依然scatter-gatherのままだと、最も遅いシャードに引きずられてレイテンシが増大します。そうなったらshard keyを見直すか、適切なインデックスを追加する必要があります。

統合ヘルスチェックスクリプト

#!/bin/bash
# scripts/health-check.sh
echo "=== Config Servers ==="
docker exec configsvr1 mongosh --quiet --eval \
  'rs.status().members.forEach(m => print(m.name, m.stateStr))'

echo "=== Shard 1 ==="
docker exec shard1rs1 mongosh --quiet --eval \
  'rs.status().members.forEach(m => print(m.name, m.stateStr))'

echo "=== Shard 2 ==="
docker exec shard2rs1 mongosh --quiet --eval \
  'rs.status().members.forEach(m => print(m.name, m.stateStr))'

echo "=== Cluster Shards ==="
docker exec mongos mongosh --quiet --eval \
  'sh.status()' | grep -E "(shards|currently|chunks)"

このクラスターは6ヶ月間、本番環境で稼働しています — 1,000万ドキュメント、平均クエリレスポンスタイムは30ms以下。成功を左右するのは2点です:最初からshard keyを慎重に選ぶこと、そして全コンテナのメモリ制限を宣言すること。どちらも後から修正できるものではありません — 私は最も厳しい方法でそれを学びました。

Share: