gh-ostを使ったMySQL Online Schema Migration実践ガイド:大規模テーブルをロックなし・無停止でスキーマ変更する

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

ALTER TABLE コマンドが悪夢になるとき

システム構築の初期、ordersテーブルには数千行しかありませんでした。ALTER TABLEは数秒で完了し、誰も気にしませんでした。しかしシステムが成長するにつれ、そのテーブルは5,000万行、サイズ80GBになりました。今日、PMからpayment_methodカラムの追加を求められました。

ステージング環境で試してみます:ALTER TABLE orders ADD COLUMN payment_method VARCHAR(50)。コマンドの実行に45分かかり、その間、ordersテーブルへの全てのINSERT/UPDATEが完全にブロックされました。本番環境では、それはサービスが45分間ダウンすることを意味し、顧客は注文できず、売上はゼロになります。

gh-ostはまさにこのような状況を解決するために生まれました。

gh-ostとは何か、その仕組み

gh-ost(GitHub Online Schema Transmogrifier)は、GitHubがロックなしでMySQLのALTER TABLEを実行するために自社開発したツールです。ALTERを直接実行する代わりに、gh-ostは4つのステップで処理します:

  1. 目的のスキーマで新しいテーブル(ghost table)を作成する
  2. 元のテーブルからゴーストテーブルへ、小さなバッチに分けてデータをコピーする
  3. コピー中、MySQLバイナリログを通じて元のテーブルへの全変更を監視し、即座にゴーストテーブルに適用する
  4. 完全に同期が取れたら、2つのテーブルを入れ替える(cutover)——このステップ全体で数秒しかかからない

なぜtriggerではなくbinlogを使うのか?

以前、多くの人がpt-online-schema-change(Percona Toolkit)を使っていました——このツールは変更を捕捉するためにtriggerを使います。問題は:triggerは書き込みの多いテーブルで大きなオーバーヘッドを生み出し、高負荷なシステムでは時にデッドロックを引き起こすことです。gh-ostはtriggerを完全に排除し、MySQLのレプリカのようにbinlogを直接読み込みます。その結果:

  • 元のテーブルにtriggerは一切存在しない——オーバーヘッドが大幅に低減
  • プロセス全体を停止せずに、リアルタイムでコピー速度をthrottleできる
  • ファイルの作成・削除によりmigrationの途中でpause・resumeが可能
  • 本番環境に触れずにreplicaで事前確認できる

以前、3,000万行のtransactionテーブルでpt-oscを使ったことがあります——triggerのオーバーヘッドでサーバーの負荷が上がり続け、常にthrottleが必要で、完了まで約8時間かかりました。同じ規模でgh-ostを使うと、負荷は制御可能な範囲に収まり、3時間で完了しました。

gh-ostのインストール

gh-ostは単一のGoバイナリで、追加の依存関係はありません:

# 最新のバイナリをダウンロード
wget https://github.com/github/gh-ost/releases/latest/download/gh-ost-binary-linux-amd64.tar.gz
tar -xzf gh-ost-binary-linux-amd64.tar.gz
chmod +x gh-ost
sudo mv gh-ost /usr/local/bin/

# バージョン確認
gh-ost --version

MySQLもROWフォーマットでbinlogを有効にする必要があります:

-- 確認
SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'log_bin';

-- 設定が正しくない場合は/etc/mysql/my.cnfに追加してMySQLを再起動する
-- [mysqld]
-- log_bin = ON
-- binlog_format = ROW

実践:本番環境での実際のテーブルmigration

user_activitiesテーブルに6,000万行あり、カラムと複合インデックスを追加する必要があるとします。

ステップ1:Dry-runで接続と権限を確認する

--executeなしで必ず先に実行します。gh-ostは全てを確認しますが、実際には何も変更しません:

gh-ost \
  --host="localhost" \
  --port=3306 \
  --user="migration_user" \
  --password="your_password" \
  --database="myapp" \
  --table="user_activities" \
  --alter="ADD COLUMN device_type VARCHAR(20), ADD INDEX idx_user_created (user_id, created_at)" \
  --allow-on-master \
  --chunk-size=1000 \
  --max-load="Threads_running=25" \
  --critical-load="Threads_running=50" \
  --verbose

ステップ2:実際のmigrationを実行する

--executeと、より細かく制御するためのいくつかのフラグを追加します:

gh-ost \
  --host="localhost" \
  --port=3306 \
  --user="migration_user" \
  --password="your_password" \
  --database="myapp" \
  --table="user_activities" \
  --alter="ADD COLUMN device_type VARCHAR(20), ADD INDEX idx_user_created (user_id, created_at)" \
  --allow-on-master \
  --chunk-size=2000 \
  --max-load="Threads_running=25" \
  --critical-load="Threads_running=50" \
  --default-retries=120 \
  --panic-flag-file=/tmp/gh-ost.panic \
  --postpone-cut-over-flag-file=/tmp/gh-ost.postpone \
  --verbose \
  --execute 2>&1 | tee /var/log/gh-ost-migration.log

ステップ3:実行中のmigrationを制御する

pt-oscや他の多くのmigrationツールとは異なり、gh-ostは調整のためにプロセスをkillする必要がありません。全ての制御はUnix socketまたはファイルシステム経由で行います:

# Unix socketでリアルタイムの進捗を確認
echo "status" | nc -U /tmp/gh-ost.myapp.user_activities.sock
# 出力例: Copy: 24500000/60000000 40.8%; Applied: 125000; Backlog: 0/1000; Time: 2h34m; ETA: 3h41m

# cutoverを遅延させる:gh-ostはコピーとbinlog読み込みを続けるが、テーブルの入れ替えはまだ行わない
touch /tmp/gh-ost.postpone

# cutoverを実行する
rm /tmp/gh-ost.postpone

# 負荷が上昇したときにchunk sizeを下げる
echo "chunk-size=500" | nc -U /tmp/gh-ost.myapp.user_activities.sock

# 即座に緊急停止
touch /tmp/gh-ost.panic

--postpone-cut-over-flag-fileフラグは特に便利です:そのファイルが存在する間、gh-ostはデータのコピーを完了しますが、テーブルの入れ替えは行いません。トラフィックが最も少ない午前2〜3時まで待ち、ファイルを削除すれば——cutoverは数秒で完了します。この方法により、コピー完了時にgh-ostが自動的に判断するのを待つのではなく、cutoverのタイミングを能動的に選択できます。

必要なMySQL権限

GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER,
      INDEX, SUPER, REPLICATION SLAVE, REPLICATION CLIENT
ON *.* TO 'migration_user'@'localhost' IDENTIFIED BY 'your_password';
FLUSH PRIVILEGES;

よくあるエラーと対処法

外部キー制約(Foreign key constraints)

dry-runを実行すれば、テーブルにforeign keyがある場合はすぐにわかります——gh-ostはサポートしておらず、何もする前に停止します。最も一般的な対処法:先にFKをdropし、migrationを実行し、cutover完了後にFKを再作成する。FKを一時的にdropできない場合(他のチームがそのconstraintに依存している場合)、代替としてアプリケーションレベルのmigrationを検討してください。

binlogが早すぎてpurgeされる

あまり注意されない問題:gh-ostはmigration中ずっとbinlogを継続して読み込みます。gh-ostが読み込む前にMySQLがbinlogをpurgeしてしまうと(expire_logs_daysが低すぎる場合)、migrationが途中で失敗します。長時間のmigrationを実行する前に、retentionを増やしてください:

-- MySQL 8.0以降
SET GLOBAL binlog_expire_logs_seconds = 604800;  -- 7日間

-- MySQL 5.7
SET GLOBAL expire_logs_days = 7;

ディスク容量不足

ゴーストテーブルは元のテーブルとほぼ同じディスク容量を消費します——開始前に最低1.2倍の空き容量が必要です。簡単な確認:

df -h /var/lib/mysql

gh-ostを使うべき場面

gh-ostは一つの問題を正確に解決します:サービスを稼働させたまま大きなテーブルのALTER TABLEを行うこと。テーブルが100〜200万行未満であれば、通常のALTER TABLEは短いメンテナンスウィンドウで十分に速く完了します——余分な複雑さは不要です。

しかし、テーブルが数千万行以上あり、サービスが24/7稼働しており、大きなインデックスを追加する必要がある場合——gh-ostはMySQLにとって最も信頼できる選択肢です。以前、100GBのデータベースをMySQLからPostgreSQLへmigrationしたとき、3日間の計画と1日の実行が必要でした——計画時間のほとんどは、相互依存するテーブルのmigration順序を決定することに費やされました。gh-ostはテーブル間の依存関係の問題は解決しません——それは自分で計画する必要があります。しかしlockの問題はきれいに解決してくれます。

注意点として:gh-ostはMySQLのみサポートしています(MariaDBはいくつかの制限あり)。PostgreSQLを使用している場合は、pg_repackやゼロダウンタイムデプロイメントによるアプリケーションレベルのmigrationなど、別のツールを探す必要があります。

Share: