Docker上でCockroachDBをデプロイ:耐障害性に優れた分散SQLクラスタの構築

Database tutorial - IT technology blog
Database tutorial - IT technology blog

クイックスタート(5分):3ノードクラスタの立ち上げ

半年前、私たちの決済ゲートウェイではプライマリデータベースのフェイルオーバー中にトランザクションがドロップする事象が頻発していました。従来のシングルインスタンス構成のPostgreSQLでは、スタンバイノードの昇格に約45秒もかかっていたのです。そのダウンタイムが注文の取りこぼしにつながったため、CockroachDBへの移行を決断しました。目標はシンプルで、手動介入なしにハードウェア障害を乗り切ることでした。ここでは、Docker Composeを使って5分以内に動作する3ノードクラスタを起動する方法を紹介します。

まず、コンテナ名でお互いを検出できるように、隔離されたブリッジネットワークを作成します。

docker network create -d bridge roach-net

次の設定を docker-compose.yml として保存します。この設定では、3つのデータベースノードと、Raftクラスタを初期化するためのワンショットコンテナを定義しています。

version: '3.8'

networks:
  roach-net:
    external: true

services:
  roach1:
    image: cockroachdb/cockroach:v23.2.0
    container_name: roach1
    hostname: roach1
    networks:
      - roach-net
    command: start --insecure --store=type=mem,size=1Gi --listen-addr=0.0.0.0:26257 --advertise-addr=roach1:26257 --http-addr=0.0.0.0:8080 --join=roach1,roach2,roach3
    ports:
      - "26257:26257"
      - "8080:8080"

  roach2:
    image: cockroachdb/cockroach:v23.2.0
    container_name: roach2
    hostname: roach2
    networks:
      - roach-net
    command: start --insecure --store=type=mem,size=1Gi --listen-addr=0.0.0.0:26257 --advertise-addr=roach2:26257 --http-addr=0.0.0.0:8081 --join=roach1,roach2,roach3
    ports:
      - "8081:8081"

  roach3:
    image: cockroachdb/cockroach:v23.2.0
    container_name: roach3
    hostname: roach3
    networks:
      - roach-net
    command: start --insecure --store=type=mem,size=1Gi --listen-addr=0.0.0.0:26257 --advertise-addr=roach3:26257 --http-addr=0.0.0.0:8082 --join=roach1,roach2,roach3
    ports:
      - "8082:8082"

  init-cluster:
    image: cockroachdb/cockroach:v23.2.0
    container_name: init-cluster
    networks:
      - roach-net
    command: init --insecure --host=roach1:26257
    depends_on:
      - roach1
      - roach2
      - roach3

バックグラウンドでクラスタを起動します。

docker compose up -d

すべてのコンテナが正常(healthy)になったら、roach1 上でインタラクティブなSQLセッションを開きます。

docker exec -it roach1 ./cockroach sql --insecure

ブラウザで http://localhost:8080 にアクセスしてください。CockroachDBに組み込まれているWebコンソールが開き、3つのアクティブノード、メモリ割り当て、クエリのスループットなどのリアルタイムメトリクスを確認できます。

アーキテクチャ、コンセンサス、PostgreSQL互換性

CockroachDBを、リードレプリカを備えた従来のPostgreSQLプライマリ構成と同じように扱ってはいけません。データベース全体を順次レプリケーションするわけではないからです。内部的には、データをチャンクに分割し、複数のノードにわたって管理されるソート済みのトランザクション対応Key-Valueストアとして動作しています。

Raftコンセンサスエンジン

CockroachDBは、すべてのテーブルをRangeと呼ばれる64MBのチャンクに分割します。3ノード構成の場合、各RangeはRaftアルゴリズムを使用して3つのノードすべてにレプリケートされます。そのうち1つのレプリカがリースホルダー(leaseholder)として機能し、読み書きの調停を担当します。

  • 書き込みには (N/2) + 1 ノードのクォーラム(定足数)が必要です。3ノードのクラスタでは、各書き込みに対して2つのノードが確認応答を返す必要があります。
  • 1つのノードがダウンしても、トランザクションは問題なく継続します。残りの2つのノードが依然としてクォーラムを満たしているためです。
  • リースホルダーがダウンした場合、残りのレプリカは9秒以内にハートビートの喪失を検出し、約300ミリ秒で後継ノードを選出します。

PostgreSQL互換性の現実

CockroachDBは、ポート26257で標準のPostgreSQLワイヤプロトコルを話します。Goの pgx、Pythonの psycopg2、Nodeの pg など、既存のドライバはカスタムパッチを適用することなくそのまま接続可能です。しかし、事前に把握しておかないと挙動の違いに足をすくわれることになります。

最も大きな違いはトランザクション分離レベルです。標準のPostgreSQLがデフォルトで READ COMMITTED を使用するのに対し、CockroachDBはデフォルトで厳格な SERIALIZABLE 分離レベルを強制します。2つのトランザクションが重複する行に同時にアクセスした場合、一方のトランザクションはSQLSTATE 40001(serialization_failure)でアボートします。そのため、アプリケーション側でこのエラーコードをキャッチし、エクスポネンシャルバックオフを用いてトランザクションを再試行する設計が必須となります。

ディザスタリカバリとデータインジェスト

耐障害性はドキュメント上では魅力的に見えますが、実際に負荷がかかっている状態でノードが消失した際に機能してこそ意味があります。ここでは、実際のフェイルオーバーの挙動検証と、効率的なデータロードのワークフローを紹介します。

予期しないノード停止のシミュレーション

roach2 に直接テストデータを書き込んでみましょう。

docker exec -it roach2 ./cockroach sql --insecure -e "
CREATE DATABASE IF NOT EXISTS shop;
CREATE TABLE shop.orders (id UUID DEFAULT gen_random_uuid() PRIMARY KEY, total DECIMAL);
INSERT INTO shop.orders (total) VALUES (49.99), (120.50);
"

ここで、roach1 を強制停止して突然のマシンクラッシュをシミュレートします。

docker stop roach1

直ちに roach3 からデータセットに対してクエリを実行します。

docker exec -it roach3 ./cockroach sql --insecure -e "SELECT * FROM shop.orders;"

クエリはわずか2ミリ秒で結果を返します。接続の切断も、フェイルオーバースクリプトの実行も、手動での対応も一切不要です。もし roach1 が5分以上ダウンしたまま(デフォルトの server.time_until_store_dead の閾値)になった場合、クラスタはレプリケーション不足になったRangeを残りの健全なノードへ自動的に再配置します。

大規模データセットのインジェスト

大きなCSVファイルをインポートする際、INSERT 文を繰り返し実行するのは避けてください。標準のInsert処理は行ごとにRaftコンセンサスを経由するため、調整スレッドがボトルネックになります。代わりに、ネイティブの IMPORT INTO コマンドを使用してください。ストレージエンジンのファイルに直接書き込むため、毎秒50,000行以上の処理を容易に達成できます。

docker exec -i roach2 ./cockroach sql --insecure -e "
IMPORT INTO shop.orders (id, total) 
CSV DATA ('nodelocal://1/initial_orders.csv');"

インジェスト前にスキーママッピングの調整やJSONへの変換が必要な乱雑なCSVファイルがある場合、toolcraft.app/ja/tools/data/csv-to-json のようなユーティリティツールを使うと、ブラウザ内で直接ファイルを解析できるため、機密データがローカル環境から外へ漏れる心配がありません。

本番運用で得られた教訓

この半年間クラスタを運用してきた中で、ローカルのDocker環境では見えなかったいくつかの落とし穴が明らかになりました。私たちが身をもって学んだ3つの重要なルールを紹介します。

1. ローカリティ(Locality)で障害ドメインを定義する

1つのEC2インスタンスやベアメタルホスト上で3つのコンテナを実行しても、耐障害性は得られません。カーネルパニックが1回発生するだけで、クォーラム全体が崩壊します。ローカル検証からステップアップする際は、コンテナを別々のアベイラビリティゾーンに分散配置し、各ノードに明示的なタグを設定してください。

--locality=region=us-east-1,zone=us-east-1a

CockroachDBはこのローカリティフラグを参照し、同一の64MB Rangeのコピーが同じアベイラビリティゾーン内に重複して配置されないように保証します。

2. 単調増加する自動採番キーを避ける

分散システムにおいて、従来の SERIAL や単調増加する整数プライマリキーの使用は危険です。シーケンシャルなIDを使用すると、後続のすべての書き込みがまったく同一のRangeリースホルダーに集中してしまいます。その結果、クラスタ内の他のノードがアイドル状態のまま、単一ノードのディスクとCPUに極端なホットスポットが発生します。代わりに、gen_random_uuid() による UUID や、CockroachDBの unique_rowid() を使用してください。どちらもクラスタ内のすべてのディスクへ書き込みを均等に分散させます。

3. メモリ上限を明示的に強制する

デフォルトでは、CockroachDBはシステムの合計RAM容量に基づいてキャッシュサイズを決定します。Docker内では、コンテナのリソース制限とホスト側の認識が一致していない場合、LinuxのOOM Killerがトリガーされる原因になります。起動時に明示的なメモリ制限を設定してください。

  • --max-sql-memory=25%: 実行中の集約処理、ハッシュ結合、ソートバッファ用のメモリ上限を指定。
  • --cache=25%: ホットな読み取りページ向けに専用のPebbleキャッシュを割り当て。

アプリケーションコードでリトライループを適切に処理し、プライマリキーを複数のRangeへランダムに分散させれば、CockroachDBはDocker上で真のゼロダウンタイム永続化を実現してくれます。

Share: