こんにちは、Digitization部で名刺データ化システムの開発をしている荻野です。 先日弊チームは、名刺データ化システムのDBをAurora2(MySQL5.7互換)からAurora3(MySQL8.0互換)へバージョンアップしました。
DBのバージョンアップは影響が大きい一方で、懸念が完全になくなるまで検証コストを掛けるのが難しい業務だと思います。 この記事では弊チームがどういう向き合い方をしたかのメモを残しておきます。 類似の中〜大規模なDBバージョンアップにどう向き合うかの知見やノウハウの一例として参考にしていただければ幸いです。
Aurora2はすでにEOLを迎えて公式ドキュメントやネットの記事なども多いため、バージョンアップに関する細かい知識や仕様の説明は割愛します。
今回のDBを取り巻くざっくりした情報
対象DBは次のような状況でした。
- 名刺データ化システム
- 社内の共通基盤として名刺のデータ化に係るAPIを提供している
- 名刺のデータ化(メインAPI)
- テキストの国判定
- 住所や部署役職の正規化
- その他
- 社内の複数システム・プロダクトから利用されている
- 社内の共通基盤として名刺のデータ化に係るAPIを提供している
- DBの規模
- DB1とDB2の2つのクラスタで構成される
- DB1:db.r6g.4xlarge、データサイズ=約23TB
- DB2:db.r6g.4xlarge、データサイズ=約1TB
- DB1とDB2の2つのクラスタで構成される
- バージョンアップの制約
- リリース時にサービスメンテは実施可能
- 実施時間はピーク時間を避け、かつ利用部門と調整する必要がある
- チームが目の前に大規模な新規開発案件を控えており、DBのバージョンアップは必要以上に工数をかけず早期に終わらせたい
- リリース時にサービスメンテは実施可能
比較的データ規模があり影響範囲は大きいものの、可用性には一定のゆるさがあった(メンテができた)ので、 手法としてはインプレースアップグレードを選択しました。
一方バージョンアップにあたって最も懸念されたのは、パフォーマンスの劣化でした。 ネット上のブログなどから、バージョンアップ後に大きな性能劣化の懸念があったほか、 弊部署の別システムが実際に、事前の検証で致命的な性能劣化によりバージョンアップを断念した事例もありました。
今回の名刺データ化システムは、SansanやEightの根幹システムであり、 サービスを維持できることを、検証でしっかり確認する必要がありました。
どこまで検証すればいいかのラインを事前に引く
本番と全く同じ構成・スペック・データ状況・インプットを再現できれば素晴らしいですが、 弊チームでそれをやるのはコスト的に非現実的でした。 なので、「バージョンアップ後も、要求されるデータ化処理量をさばけること」 を防衛ラインとして検証することにしました。
より具体的には、次の観点を確認できれば十分だと考えました。
- ピーク時間帯の流入以上のデータ化スループットを出せること
- 最悪のケースでは、データ化処理以外の周辺バッチなどを停止してでも、スループットを維持できること
- 性能劣化でスループットを維持できない場合に、クラウドリソースを増強して対処可能なこと
- 実際今回の検証でも懸念が出たのですが、ロック競合などでlockwaitが多発するワークロードでは、スケールアップでは理論上どうにもならない負荷があり得るため、確認しておきたい
低コスト低リターンな検証から始める
主要なリスクを早期に発見するため、検証を松竹梅の3段階に分けて実施しました。
1.クエリ単体レベルの検証 (1〜数日で可能)
次のような懸念のあるクエリは、ピンポイントでのベンチマークと実行計画の確認をしました。
- Aurora/MySQLのリリースノートから、影響が懸念されるクエリ
- AWSのPerformance Insightsから分かる、負荷の高いクエリ
- システムにおいてクリティカルなワークロードのクエリ
手法としては、本番環境のインスタンスをクローンしたうえで、MySQL Workbenchを使用してスループットやPerformance Schemaの内容を確認しています。 2〜3日でクリティカルな性能劣化がないかを確認できました。
2.監査ログを用いたリプレイ(数日程度で可能)
ピーク負荷時のSQLを監査ログから抽出し、それを再現することで、実際の負荷に近い状況で検証しました。 参照系に限定した負荷に関しては、この方法で本番にかなり近い負荷を再現できたので、コストパフォーマンスの良い検証だと思います。 状況や環境によっては、この検証までで完了するというのもありかもしれません。
Aurora MySQLの監査ログは、概ねカンマ区切りのフォーマットをしていたため、Pythonのpandasを使用することで比較的容易に分析やSQLの抽出ができました。 今回は抽出したSQLをそのままMySQL Workbenchに食わせましたが、より複雑なシナリオテストを他の負荷試験ツールで作成することなどもできそうです。
ログによるリプレイの欠点として、CUD系のクエリのリプレイはうまくいかない環境が多いと思います。 理由は、過去と同じクエリをリプレイすることで、ユニーク制約などに引っかかる可能性が高いためです。 弊チームのDBは、パフォーマンスを優先してDBを極力正規化しない設計を取っていたため、ある程度CUD系のクエリもリプレイできましたが、 それでも本番と異なる挙動はあり満足行く再現は難しかったです。
from io import StringIO import logging import pandas as pd from pathlib import Path def autit_log_to_df(folder_path): """ 指定されたフォルダ内の全てのログファイルを読み込み、1つのDataFrameに結合します。 """ log_files = Path(folder_path).rglob('*') dataframes = [] for path in log_files: if path.is_dir() or 'DS_Store' in str(path): continue logging.info(f"Processing file: {path}") try: with path.open('r', encoding='utf-8') as f: # SQL文中のシングルクォートをCSVエスケープに変換 # 検証に大した影響のない要素だったのでこれで済ませていますが、要件によってはより厳密なエスケープが必要かもしれません content = f.read().replace("\\'", "''") io = StringIO(content) part_df = pd.read_csv(io, quotechar="'", header=None) part_df.columns = [ 'timestamp', 'serverhost', 'username', 'host', 'connectionid', 'queryid', 'operation', 'database', 'object', 'retcode' ] dataframes.append(part_df) except Exception as e: logging.error(f"Failed to process {path}: {e}") if dataframes: combined_df = pd.concat(dataframes, ignore_index=True) return combined_df else: return pd.DataFrame(columns=[ 'timestamp', 'serverhost', 'username', 'host', 'connectionid', 'queryid', 'operation', 'database', 'object', 'retcode' ]) def extract_query(df): """ 'operation' が 'QUERY' の行を抽出します。 """ query_df = df[df['operation'] == 'QUERY'].copy() logging.info(f"Extracted {len(query_df)} QUERY operations") return query_df def extract_select_query(df): """ 'object' 列が 'SELECT' で始まる行を抽出します。 """ select_df = df[df['object'].str.startswith('SELECT')].copy() logging.info(f"Extracted {len(select_df)} SELECT queries") return select_df def add_no_cache_to_select_query(df): """ SELECTクエリに SQL_NO_CACHE を追加します。 (MySQL Workbenchでベンチマーク取得する際にクエリキャッシュを無効化するための処理) """ df.loc[df['object'].str.startswith('SELECT'), 'object'] = \ df.loc[df['object'].str.startswith('SELECT'), 'object'].str.replace( r'^SELECT', 'SELECT SQL_NO_CACHE', regex=True ) logging.info("Added SQL_NO_CACHE to SELECT queries") return df def add_ignore_to_insert_query(df): """ INSERTクエリに IGNORE を追加します。 CUD系のクエリをリプレイする際に、重複エラーを無視するための処理 """ df.loc[df['object'].str.startswith('INSERT'), 'object'] = \ df.loc[df['object'].str.startswith('INSERT'), 'object'].str.replace( r'^INSERT', 'INSERT IGNORE', regex=True ) logging.info("Added IGNORE to INSERT queries") return df def main(): # 全ログファイルをDataFrameに結合 df = autit_log_to_df('log_dir') df['timestamp'] = pd.to_datetime(df['timestamp'], unit='us') df.set_index('timestamp', inplace=True) # QUERY操作のみを抽出してみる df = extract_query(df) # 特定のSELECTクエリを含むログを抽出してみる target_condition = "SELECT `some_table`" df = df[df['object'].str.contains(target_condition)].copy() # ユニークコネクションの数を見てみる unique_connections = df['connectionid'].nunique() print(f'ユニークコネクション数(≒並列数): {unique_connections}') add_no_cache_to_select_query(df) print(df['object'].head(5))
ステージング環境での負荷試験(2週間〜)
最後に、ステージング環境で可能な限り本番に近い構成を再現して、本番相当のデータを流し込んで試験しました。 最終的にこの試験によって、CUD系のクエリも含めて、本番のピーク時間帯を超えるスループットをシステム全体として出せたため、バージョンアップをリリースして良いと判断できました。 ただし、現場にも寄りますが、DB以外も含めたシステム構成を再現するのは非常にコストが大きく、本番環境との思わぬ差異を網羅的に把握することも難しいため、実施する場合は相応の覚悟が必要と思います。
実施していない検証
逆に、実施していない検証もあります。
- 本番と同じデータセットを投入した負荷試験環境を新規に構築する案
現状のステージング環境ではセキュリティ等の観点から本番同様のデータを投入できないという恒常的な課題がありました。 これの解決も兼ねて、本番同様のテストデータを扱える環境を新規AWSアカウントなどに構築する案もありました。 しかし、この案はスケジュールに対して明らかに工数が大きすぎ、かつ、新規環境構築しての恩恵が見通しきれないため断念しました。
- ステージングにおいて、主要ワークロード以外の周辺処理も含めた結合試験をする案
ステージングでの負荷検証は、名刺のデータ化に関する主要なワークロードに関するシナリオに限定しており、 それ以外の雑多なバッチ処理などは検証対象から外しました。 夜間バッチなどは集計系の処理などが多いためMySQL8の特性による性能劣化などが懸念されるところではあるのですが、 最悪の場合はバッチ処理などは一時停止してもクリティカルなサービスの提供は継続できるため、優先度を落としました。 サービスを維持するためのクリティカルなワークロードにおける劣化や不具合を切り分けることに専念したことになります。
まとめ
今回のAuroraのDBバージョンアップに際し、事前検証を通じて、主要リスクに対応しました。 基本的なことですが、検証のゴールを明確にすること、小さな検証から始めてリスクを早期に洗い出すこと、の2点が大事だったという話でした。 データ規模や負荷特性に合わせた検証プロセスを検討する際の参考になれば幸いです。