この記事はエムスリー Advent Calendar 2020 1日目の記事です。 明日からも面白い記事が続々投稿されるので、ぜひ購読・拡散お願いします!
エムスリー エンジニアリンググループの瀬越です。 医療に貢献するプロダクト『エムスリーデジカル』を日々作っています。
突然の告知ですが 12/14(月) 19:00~ 「急成長を続けるクラウド電子カルテ『エムスリーデジカル』技術 & 組織大公開!」と題して、エンジニア採用説明会を行います! 技術だけでなく、ビジネス・組織のチャレンジまで赤裸々に語りますので、是非ご参加ください!
今回は Delayed::Job の性能劣化に pg_repack + Sidekiq で挑んだ話をします!
課題
2015年10月にスタートした本サービスは、2019年8月に利用施設数1,100件、2020年10月には2,000件を突破しました*1。
AWS上で稼働しており、サービス開始当初から DB は Aurora PostgreSQL 、非同期処理には Delayed::Job を使っていましたが、利用施設数の増加に伴って以下のような問題が顕在化しました。
- index の肥大化によるクエリパフォーマンスの悪化
- Delayed::Job + 追記型アーキテクチャである PostgreSQL による問題
- 具体的にはJobのロック獲得時のクエリパフォーマンスが悪化していた
- autovacuum が間に合っておらず、 dead tuple を十分回収し切れていなかった
- 一部非同期処理のSLO超過
- 上記パフォーマンス悪化によりジョブが滞留し、一部機能がSLOを超過してしまった
- autovacuum*2 の実行による DB CPU 圧迫
autovacuum_max_workers
はデフォルト値の 3 のままだったが、大量のデータが更新される( DB CPU 負荷が上昇する)タイミングで autovacuum が走り、CPUを圧迫してしまっていた
どうやって解決するか
システム全体の水平分割は並行して進めていた*3ものの、すでに上記課題が顕在化しており、水平分割の完成まで放置できなかったため、以下の2つの方法で対処することにしました。
- pg_repack の定期実行
- 一部ジョブの Delayed::Job -> Sidekiq への置き換え
pg_repack の定期実行
肥大化したテーブル・インデックスの "reorganize" をしてくれる pg_repack を、以下のようなアーキテクチャで定期的に実行する仕組みを構築しました。
その際、pg_repack のドキュメントや周辺情報を参考にしつつ、ユーザー影響を最小限にするべく以下の点を考慮しました。
--no-kill-backend
の指定
pg_repack のデフォルトの挙動として、指定秒数以上経っても排他ロックが取得できない場合競合するクエリを停止してしまいます*4。
--no-kill-backend
オプションを指定することでクエリの停止によるユーザー影響を避けることはできますが、テーブルの repacking がスキップされたことを検知できない状態だと、dead tuple が溜まってしまい今回達成したかったことが実現できなくなってしまいます。
そこで、CloudWatch Logs Metric Filter + CloudWatch Alarm で pg_repack がスキップされた場合にエンジニアに通知される仕組みを整えました。
以下、 terraform の記述例です。
resource "aws_cloudwatch_log_metric_filter" "vacuum_skipped_table" { name = "vacuum-skipped-table" pattern = "Skipping repack" log_group_name = aws_cloudwatch_log_group.vacuum.name # pg_repack のログが出力される CloudWatch Log Group metric_transformation { name = "vacuum-skipped-table" namespace = "LogMetrics" value = "1" default_value = "0" } } resource "aws_cloudwatch_metric_alarm" "vacuum_skipped_table_alarm" { alarm_name = "vacuum-skipped-table" alarm_description = "pg_repack がロックを獲得できずに処理をスキップしたテーブルがあるときのアラート" comparison_operator = "GreaterThanOrEqualToThreshold" threshold = "1" metric_name = "vacuum-skipped-table" namespace = "LogMetrics" statistic = "Sum" period = "60" evaluation_periods = "1" alarm_actions = [var.sns_topic_arn] }
安全のための仕掛け
pg_repack はアクセスが少ない深夜時間帯に実行する予定でしたが、アクセスが増加し始めるまでには確実に終わっていてほしく、また、実行時間が伸びた場合にもキャッチできるような仕組みが必要でした。
そこで、以下の2つのパターンのときに終了コード1で終了し、CloudWatch Event Rule によって通知されるようにしました。
実行に3時間以上かかったとき
アクセス増加までには完了しているが、実行時間が伸びていて将来ユーザー影響が出る可能性が認められるケースでは pg_repack 処理は完了させ、通知のみ行います。
具体的には、 pg_repack を実行する shell script に以下の記述を追加します。
if [ $SECONDS -gt 10800 ]; then echo "Elapsed time greater than 10800 seconds!" >&2 exit 1 fi
実行に6時間以上かかったとき
ユーザー影響が出る可能性があるケースでは pg_repack を強制停止した上で通知します。
具体的には、 Dockerfile で以下のように記述します。
CMD timeout 6h ./scripts/exec_pg_repack.sh || exit 1
Delayed::Job -> Sidekiq
将来の利用施設数の増大によって pg_repack による延命も効果が限定的であると考えたため、 pg_repack に加えて一部ジョブの Sidekiq への移行も実施しました( Delayed::Job, Sidekiq のパフォーマンスに関する話は散々語られているかと思うので割愛します)。
Sidekiq はWikiが充実しているため、導入を考えている方は一通り目を通しておくことをオススメします。
実装にあたっては以下のような点に注意しました。
- ジョブの冪等性の担保
- 実行頻度の多いジョブだけを Sidekiq に移行することで影響範囲を減らす
- Sidekiq が提供する API*5 から占有率・latencyを算出し、ECS task を Auto Scaling させジョブの滞留を防ぐ
- 環境変数によって Delayed::Job ↔ Sidekiq の切替を可能にし、切り戻しを容易にする
- あらかじめ監視の観点を洗い出しておくことにより、リリース後の切り戻し判断を素早く行えるようにする
- ジョブの遅延: latency, Queue Size
- Redisのキャパシティ不足: CPU使用率・メモリ使用率
まとめ
pg_repack の対応を先にリリースしたことで、 Aurora の CPU 負荷の急騰を抑えることができ、影響範囲を局所化した上で Sidekiq への移行を実施できました。
成長し続けるクラウド電子カルテには仲間が必要です!!
12/14(月) 19:00~のエムスリーデジカルの採用説明会では、私から『15分でエムスリーデジカル体験入社』という、たった15分でデジカルに join した気になれる発表をします。 他にも執行役員 VPoE の山崎やテックリードの山口から発表があり、様々な角度からデジカルを知っていただける絶好の機会となっております。奮ってご参加ください!
また、僭越ながら自分のインタビュー記事が公開されたので、こちらもご覧ください。
イベントに来られない方はカジュアル面談もお待ちしています!
*1:No.1クラウド電子カルテ「エムスリーデジカル」の導入件数が2000件を突破! ~3600万人分の患者の診療を実現~ / エムスリーデジカル株式会社
*2:Amazon RDS for PostgreSQL 環境の自動バキュームを理解する | Amazon Web Services ブログ
*3:詳しい内容はBIT VALLEY 2020 にて『 Infrastructure as "型付き" Code - 急成長する事業のインフラ再構築 』を発表しました をご覧ください!
*4:PostgreSQL 8.4以上のバージョンを利用している場合、指定した時間の2倍以上経ってもロックが取得できない場合、pg_repackは競合するクエリを実行しているPostgreSQLバックエンドプロセスをpg_terminate_backend()関数により強制的に停止させます pg_repack 1.4.6