29億行のMySQL 8.4 テーブルのDBマイグレーション。gh-ostでサービスを止めずにPK型更新を実現した話

はじめに

こんにちは!ウィルゲート開発室 SRE ことみん(@kotomin_m)です!

このブログでは、MySQLの1テーブルのプライマリーキー(以降PKと呼ぶ)型更新のマイグレーションを、github/gh-ost(ゴースト)で実施した際の調査〜本番実施までを詳しく紹介しています。

gh-ostは初めて利用したツールでしたが、本番環境での実施まで上手く使うことでとても便利なツールだったので、似たような課題で困っている方の参考になると嬉しいです!

DBマイグレーションを行う背景

分析データを日々保存(INSERT)するプロダクトにおいて、最大規模のテーブルのプライマリーキー(PK)が29億を超えてきていました。

当該カラムのデータ型は INT UNSIGNED なので、最大値は約42億です。現在のデータ増加ペースと、今後の利用者増に伴う分析量の増加を試算すると、あと1〜2年で上限に到達する見込みであることがわかりました。

サービスを続けていくためには不可避の対応であると判断し、今回の型実施の実施に至りました。

gh-ostとは?

github/gh-ost はGitHubが開発・公開しているオープンソースのMySQL用オンラインスキーマ変更ツールです。

github.com

大きな特徴は、コピーテーブル(ゴーストテーブルと呼ばれる*1)を作成した上で、テーブルをチャンク(小さな塊)ごとに分割してコピーを行い、バックグラウンドでスキーマ変更を進める設計にあります。これにより、サービスへの影響(ダウンタイム)を最小限に抑えたマイグレーションが可能です。

gh-ostの基本的な挙動・メリット

以下の手順でテーブルのマイグレーションが行われます。

  1. 元テーブルのゴーストテーブルを作成
  2. 元テーブルのデータを少しずつゴーストテーブルへ INSERT
  3. 移行中に元テーブルで発生した更新(INSERT/UPDATE/DELETE)をbinlogから読み取り、リアルタイムにゴーストテーブルへ反映
  4. データの同期が完了したタイミングで、RENAME TABLEにより元テーブルとゴーストテーブルを瞬時にスイッチ

詳細な公式ドキュメントはこちらです。

ゴーストテーブル(概念)

実施の前提条件・概要

今回は、PKのデータ型をINT UNSIGNEDからBIGINT UNSIGNEDへ拡張しました。

IDの枯渇対策としては「IDの採番効率を上げる(更新頻度を下げる)」というアプローチも検討しましたが、データ構造の変更やアプリケーションコードの大幅な修正が必要となり、リスクと工数が高くなります。そのため、今回は移行コストが低く確実性の高い型定義の変更という方針を採用しました。

なお、対象のカラムは外部から参照されていなかったため、型変更によるアプリケーションへの影響もないことを確認しました。

RDS設定変更に伴うメンテナンスについて(補足)

ブログタイトルに「サービスを止めずに」と書きましたが、厳密には完全な無停止ではなく、RDSの設定変更による再起動での瞬断を伴うメンテナンスを行っています。

gh-ost自体の実行はオンラインで行えますが、事前検証の結果、私たちの環境ではパフォーマンス確保のためにMySQLのストレージ設定変更が必要であることが分かりました。

この設定変更にはDBインスタンスの再起動が必要なため、その前後でメンテナンス時間を設けています。

詳しくは次の章で解説します。

事前検証(失敗〜成功)

本番環境での安全な移行を目指し、日次スナップショットから作成した検証用インスタンスを用いて実験を開始しました。

最初からスムーズには進まなかった点も含めて、このブログでは紹介していきます。

検証環境の構築とgh-ostの挙動確認

検証用RDSインスタンスの作成

  1. gh-ostをインストール
  2. パラメータグループをコピーし、gh-ostに必要な binlog_format=ROW に変更
  3. 2で作ったパラメータグループを利用したインスタンスをスナップショットから作成

gh-ostコマンドの実行

rm -f /tmp/ghost.sock

nohup ./bin/gh-ost \
  --host=ホストURL --user=ユーザ名 --password=パスワード --database=DB名 --table=テーブル名 --assume-rbr --allow-on-master --serve-socket-file=/tmp/ghost.sock \
  --alter="MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'テーブルID'" \
  --chunk-size=10000 --nice-ratio 0.1 \
  --execute > gh-ost.out 2>&1 &

# 実行直後のステータス確認
tail gh-ost.out

実行エラーにはなっておらず、マイグレーションは進みました。

今後、chunk-size nice-ratio 等のパラメータはチューニング余地がありそうです。

実行中の参照・更新確認

マイグレーション実行中も、元テーブルに対してクエリが実行できることを確認しました。

-- 参照ができるか
SELECT id FROM テーブル名 ORDER BY id LIMIT 1;

-- 追加ができるか
INSERT INTO `テーブル名` (`id`, `hoge_id`, `fuga_piyo`, `created_at`, `updated_at`)
VALUES (1, 2, テスト', NOW(), NOW());

課題:実行時間の長期化

マイグレーションの進捗確認

マイグレーションの進捗は、出力される gh-ost.out ファイルを tail して確認することができます。

$ tail gh-ost.out
...
Copy: 183460000/2349927477 7.8%; Applied: 0; Backlog: 0/1000; Time: 1h0m0s(total), 1h0m0s(copy); streamer: mysql-bin-changelog.020574:90642648; Lag: 0.04s, HeartbeatLag: 0.05s, State: migrating; ETA: 10h1m47s
  • Copy : 進捗
  • Applied: 変更済み行数
  • Backlog: 開始後にinsertされた行数
  • Time: 経過時間
  • ETA: 残り時間 (目安)

最初の検証では、開始直後のETAは「約32.5時間」でしたが、40時間経過した時点で進捗率28%(約6億行)に対し、ETAが「109時間」まで悪化してしまいました。Performance Insightsを確認したところ、INSERT クエリのレイテンシが 400ms から 2000ms へと大幅に増加していました。

仮説と検証:I/Oボトルネックの解消

時間経過に伴ってINSERT時間が伸びてるので、「I/O部分がネックになっている」と当たりをつけ、ストレージをgp2からgp3にして、最大の16,000IOPSに引き上げると書き込み性能が改善し、1.5倍程度速くなると仮説を立てました。

これに対して調査したところ、利用していたdb.r5.largeは「EBS最適化スループット」の上限によって、これだけではマイグレーション時間の変化は無さそうということがわかりました。たとえストレージ側でどれだけ高いIOPSやスループットをプロビジョニングしても、インスタンスとストレージを結ぶパイプが天井になってしまっていました。

より高いI/O性能を持つdb.r5b.2xlargeへに変更して検証してみます。

r5bは、通常のr5シリーズに比べてEBS最適化帯域が大幅に強化されたモデルです。また、2xlargeへのスケールアップにより、EBS最適化帯域を約15倍に引き上げます。

  • インスタンス: db.r5.largedb.r5b.2xlarge
  • ストレージ割当: 3,545 GiB → 4,500 GiB
  • IOPS/スループット: 大幅に増強(22,000 IOPS / 625 MB/s)

スペックアップによるコストも事前に計算すると、1日当たり1万円のコスト増という試算になったので数日に絞って検証を行いました。

検証結果は成功!🎉

書き込み性能が劇的に改善され、「約29時間」で完了するようになりました。

その他に検証時に遭遇した内容

検証を通じて発見した細かい注意事項をまとめて紹介します

一次停止と再開

以下のコマンドを実行することで、マイグレーションの一次停止・再開を行うことができます。

# 一次停止
echo throttle | nc -U /tmp/ghost.sock

# 再開
echo no-throttle | nc -U /tmp/ghost.sock

パスワード長制限

マイグレーションに使うDBユーザのパスワードが32文字以上だとgh-ostが動かない問題がありました。長過ぎるパスワードには気をつけてください。

パスワードの文字

パスワードに ビックリマーク ! が入っているときに実行エラーになりました。

シングルクウォートで囲う形式(--password='z!hogehoge')で実行するとできます。ややこしい記号は使わないよう気をつけてください。

binlogの設定

検証用インスタンスでバックアップ保持期間を0にしていると、binlogoffになってgh-ostが動かない問題がありました。gh-ostはbinlogが必須なため、検証環境でも必ず「1日以上」に設定してください。

本番マイグレーション実施・結果

本番環境での実行は、検証環境とは異なり通常のサービス負荷がかかった状態で行われます。そのため、検証時よりも処理時間が延びることを前提に計画を立てました。

負荷軽減のための機能制限

gh-ostの実行負荷を最小限に抑え、移行時間を短縮するために、以下の対策を行いました。

  • 対象テーブルへの書き込みを行うバッチを停止
  • 毎日実行されているDB負荷が大きいバッチを停止

こうした機能制限が可能かどうかはプロダクトの性質に依存しますが、今回はアプリ開発チームと密に連携し、「DBへの負荷を下げつつ、プロダクトへの影響を最小に、安全にマイグレーションを実行できるか」を事前に合意できたことが、大きな成功要因となりました。

実施結果

実際のマイグレーション作業は、アプリチームと連携したDBスペック変更のためのメンテナンス(再起動を伴うもの)を前後に挟む形で行いました。

結果として、約29億行のデータ移行を「約17.5時間」で無事に完了させることができました!🎉

検証段階でETA(完了予測時間)の悪化を経験し、事前にインスタンスタイプやストレージスペックを計算していたこと、そして詳細な手順書を作成していたことが、本番でのスムーズな進行に直結しました。

おわりに

調査・検証を開始してから本番実施まで、長い期間だったので成功できて嬉しいですね!

長くプロダクトを運用していると、「リリース当初は問題なかった部分」が、数年後の成長とともに技術的な課題として浮き彫りになってくることがあります。

SREをやっているとプロダクトやユーザに向き合うこと以外にも、システムの根幹を支える技術的な挑戦に深く関われて楽しいですね。

最後まで読んでくれて、ありがとうございました!!