深夜2時14分のPagerDutyアラート:スプリットブレインの悪夢
深夜2時14分、ベッドサイドテーブルの上でスマートフォンが激しく振動しました。PagerDutyから30秒の間に3件のアラートが通知されます。決済用のプライマリデータベースが突如カーネルパニックを起こしたのです。プライマリDBはプライベートネットワークから切断され、US-EastおよびEU-Westリージョンにまたがる14のAPIマイクロサービスが一斉にHTTP 500エラーを吐き出し始めました。その一方で、スタンバイ状態のレプリカは決して届くことのない書き込みを待ち続け、ただ待機している状態でした。
私はふらつきながらデスクへ向かい、ターミナルを開いて、レガシーなレプリケーション座標画面を見つめました。従来のファイル名とポジション番号による方式では、レプリカを昇格させるために、レプリカが停止した正確なログファイル(mysql-bin.000412)と正確なバイトオフセット(4829104)を特定しなければなりません。処理中のトランザクションが失われていないことを確認した上で、残り2台のリードレプリカの接続先を手動で再設定する必要がありました。
アドレナリンが急上昇する緊迫した状況の中、オンコール担当のエンジニアがオフセット番号の数字を1桁入力ミスしてしまいました。わずか4分後、セカンダリレプリカの半数がError 1062: Duplicate entryでクラッシュし、残りはロック状態に陥り、サポートチャンネルには42件の二重決済の問い合わせが殺到しました。このデータの不整合を解消するのに、2時間もの苦痛に満ちた時間を費やすことになったのです。
障害対応の最中に手動でファイル名やオフセットの計算を行うことは、データの整合性に対する直接的な脅威です。推測や勘に頼るのではなく、決定論的にフェイルオーバーを行えるよう、トランザクションの識別子をクラスタ全体でグローバルに追跡する仕組みが不可欠です。
根本原因:なぜ従来のバイナリログ座標は緊急時に破綻するのか
従来のMySQLレプリケーションは「座標」に依存しています。各レプリカは特定のバイナリログファイル名(例:binlog.000102)とその内部のバイト位置(例:712)を追跡します。概念としてはシンプルですが、ハードウェアの予期せぬ障害が発生した瞬間、この仕組みは破綻します。
MySQLの各インスタンスは、独立した独自のバイナリログストリームを書き込みます。プライマリがクラッシュした場合、そのローカルファイル名とオフセットは、下流のレプリカで生成されたものとは一致しません。プライマリP、レプリカA、レプリカBというトポロジーを例に考えてみましょう。
Pが停止した際、レプリカBに対して「レプリカAのオフセット5512から再開せよ」と指示することはできません。レプリカAのバイナリログの番号付けは、プライマリのストリームとは何の関係もないからです。手作業でトランザクションヘッダーを確認し、両ノード間でオフセットを突き合わせて計算し、緊迫した状況下で計算ミスがないことを祈るしかありません。4〜5台のレプリカを抱える大規模環境では、1回のクラッシュによって何時間もの手作業による調査と復旧作業が発生しがちです。
レプリケーション方式の比較:ファイル/オフセット方式 vs GTID方式
40ノードに及ぶ本番データベース環境の標準化を行う前に、従来の座標方式とGTID(Global Transaction Identifier:グローバルトランザクション識別子)の運用面におけるトレードオフを比較検討しました。
1. ファイル&ポジション形式のレプリケーション(従来方式)
- 変更の追跡方法: 明示的なファイル名とバイトオフセットに依存(例:
MASTER_LOG_FILE='binlog.000004', MASTER_LOG_POS=157)。 - フェイルオーバーの複雑さ: 非常に高い。スタンバイ機の昇格には、面倒なログ比較や、サーバー間でポジションを変換する壊れやすいBashスクリプトが必要になります。
- データ不整合リスク: 高い。誤ったバイト位置から再開すると、データが警告なくスキップされたり、重複キーエラーでレプリケーションが異常終了したりします。
- 運用オーバーヘッド: 高い。インスタンスの交換や昇格を行うたびに、トポロジー全体で手動の再計算が必要になります。
2. グローバルトランザクション識別子(GTID方式)
- 変更の追跡方法: コミットされたすべてのトランザクションに、クラスタ全体で一意かつ不変のUUID(
source_id:transaction_id形式、例:3E11FA47-71CA-11E1-9E33-C80AA9429562:23)を付与。 - フェイルオーバーの複雑さ: 最小限。レプリカは実行済みトランザクションの正確な台帳を保持します。
SOURCE_AUTO_POSITION = 1を設定するだけで、新しいソース側が自動的に差分を特定し、不足しているトランザクションのみを配信します。 - データ不整合リスク: ほぼ皆無。レプリケーションエンジンがストレージエンジン層でトランザクションの境界と冪等性を検証します。
- 運用オーバーヘッド: 低い。OrchestratorやProxySQLなどのオーケストレーションツールとスムーズに連携でき、手動での昇格も極めて容易です。
実装手順:GTIDマスター・スレーブ構築ステップバイステップ
以下は、確実なフェイルオーバーを実現するために、MySQL 8.0をホストするUbuntu 22.04 LTSインスタンスで実際に運用しているデプロイの設計手順です。
ステップ1:プライマリ(ソース)サーバーの設定
プライマリノードの設定ファイル(/etc/mysql/mysql.conf.d/mysqld.cnf)を開きます。
[mysqld]
server-id = 101
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW
gtid_mode = ON
enforce_gtid_consistency = ON
log_replica_updates = ON
binlog_checksum = CRC32
MySQLデーモンを再起動してGTIDサポートを有効化します。
sudo systemctl restart mysql
次に、プライマリ上で必要最小限の権限を付与したレプリケーション用アカウントを作成します。
CREATE USER 'repl_user'@'192.168.1.%' IDENTIFIED BY 'StrongAuthPass_2026!';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl_user'@'192.168.1.%';
FLUSH PRIVILEGES;
ステップ2:レプリカ(ターゲット)サーバーの設定
スタンバイレプリカ側で、/etc/mysql/mysql.conf.d/mysqld.cnfを編集します。server-idがインフラ全体で重複しないよう一意の値を設定してください。
[mysqld]
server-id = 102
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW
gtid_mode = ON
enforce_gtid_consistency = ON
log_replica_updates = ON
read_only = ON
super_read_only = ON
binlog_checksum = CRC32
read_onlyとsuper_read_onlyを有効にすることで、アプリケーションクライアントや、SUPERまたはSYSTEM_VARIABLES_ADMIN権限を持たないユーザーからの書き込みトラフィックをブロックします。レプリカを再起動します。
sudo systemctl restart mysql
ステップ3:GTID状態を含むクリーンなスナップショットの取得
本番トラフィックを中断することなくレプリカを初期化するために、mysqldump(あるいは大容量データベース向けのPercona XtraBackupによるホットバックアップ)を使用して整合性のあるスナップショットを取得します。--source-data=2フラグ(古いMySQLバージョンの場合は--master-data=2)を使用すると、プライマリの実行済みGTIDセットがSQLヘッダーに直接記録されます。
mysqldump -u root -p \
--all-databases \
--single-transaction \
--quick \
--triggers \
--routines \
--source-data=2 \
> /tmp/primary_dump.sql
スナップショットをローカルネットワーク経由でレプリカに転送し、リストアします。
scp /tmp/primary_dump.sql [email protected]:/tmp/
mysql -u root -p < /tmp/primary_dump.sql
システム間でのインポートを実行したり、生のCSVログからテスト用フィクスチャを作成したりする際、文字コードやエンコーディングの問題によく遭遇します。手軽にローカルでデータ変換を行いたい場合、toolcraft.app/ja/tools/data/csv-to-jsonのようなブラウザベースのツールを使えば、サードパーティのAPIに機密性の高い本番データを送信することなく、クライアントサイドでファイルをパースできます。ステージングサーバー上で使い捨てのPythonスクリプトを書く手間が省けるため重宝しています。
ステップ4:オートポジショニングによるレプリカの接続
GTIDがトランザクションを直接追跡するため、ログファイル名やバイトオフセットを指定する必要はありません。スタンバイノードでMySQLにログインし、プライマリに接続します。
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = '192.168.1.101',
SOURCE_PORT = 3306,
SOURCE_USER = 'repl_user',
SOURCE_PASSWORD = 'StrongAuthPass_2026!',
SOURCE_AUTO_POSITION = 1;
START REPLICA;
レプリケーションパイプラインのステータスを確認します。
SHOW REPLICA STATUS\G
両方のスレッドが実行中であり、GTIDセットが同期されていることを確認します。
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 0
Retrieved_Gtid_Set: 3e11fa47-71ca-11e1-9e33-c80aa9429562:1-450
Executed_Gtid_Set: 3e11fa47-71ca-11e1-9e33-c80aa9429562:1-450
本番環境での安全なフェイルオーバー:プライマリ障害への対処
プライマリサーバーがダウンした場合は、データ損失を防ぎつつレプリカをクリーンに昇格させるため、以下の手順を実行します。
ステップ1:障害が発生したプライマリの隔離
まず、クラッシュしたノードをフェンシング(隔離)します。インシデント対応の最中にサーバーが突然再起動した場合に備え、書き込みを受け付けないようにする必要があります。仮想IPアドレス(VIP)を剥奪するか、セキュリティグループを更新するか、またはクラウドプロバイダのAPI経由でサーバーをシャットダウンします。
ステップ2:レプリカ側で処理中トランザクションの完全適用
レプリカ(192.168.1.102)にログインします。I/Oスレッドを停止して中途半端なパケットの受信を防ぎつつ、SQLスレッドにはローカルのリレーログキャッシュをすべて処理させます。
STOP REPLICA IO_THREAD;
SELECT WAIT_FOR_EXECUTED_GTID_SET(@@global.gtid_retrieved);
STOP REPLICA;
ここでWAIT_FOR_EXECUTED_GTID_SETは@@global.gtid_retrievedを監視する必要がある点に注意してください。このクエリは、I/Oスレッドが過去に取得したすべてのトランザクションをSQLスレッドが実行し終えるまで処理をブロック(待機)します。
ステップ3:スタンバイノードのプライマリ昇格
不要になった古いレプリケーションメタデータを消去し、読み取り専用ロックを解除して読み書き可能な状態に戻します。
RESET REPLICA ALL;
SET GLOBAL super_read_only = OFF;
SET GLOBAL read_only = OFF;
次に、接続層の設定を更新し、DNS CNAMEの切り替え、仮想IP(VIP)の移行、ProxySQLのバックエンドプールの更新などを行って、アプリケーションのトラフィックを192.168.1.102へと誘導します。
ステップ4:残存レプリカの接続先を新プライマリへ切り替え
残存するリードレプリカは、わずか3つのコマンドで新しく昇格したプライマリへと接続先を切り替えることができます。残りの各ノードで以下を実行します。
STOP REPLICA;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = '192.168.1.102',
SOURCE_AUTO_POSITION = 1;
START REPLICA;
レプリカは自身のローカルGTID台帳を新しいプライマリに提示します。プライマリは不足しているトランザクションを特定してストリーミング配信し、リアルタイムのレプリケーションを再開します。オフセット計算も、人為的な計算ミスも、推測による判断も一切不要です。

