DMM プラットフォーム事業本部 マイクロサービスアーキテクトグループのいっぬ(@yuyu_hf)です。
先日、DMMグループで実施しているGoの勉強会「DMM.go #4」で、負荷試験基盤の開発について発表しました。
- DMM.go #4 https://dmm.connpass.com/event/246631/
- 発表で使用したスライド https://speakerdeck.com/yuyu_hf/dmm-go-4-load-testing-first-release
今回のブログでは発表の様子と、スライドに使用したスクリプトと質疑応答を載せています。
マイクロサービスプラットフォーム向け負荷試験基盤の初期リリースを終えた話
いっぬ: 本日は「マイクロサービスプラットフォーム向け負荷試験基盤の初期リリースを終えた話」についてプラットフォーム事業本部マイクロサービスアーキテクトグループのいっぬが発表させていただきます。
今回、一ヶ月前にリリースした負荷試験基盤に関してどのような負荷試験基盤を作ったかについてお話しようと思います。
はじめに、今回の負荷試験基盤の特徴は二つあります。一つ目は、Goで試験スクリプトが書けること。二つ目は、k8sを使って分散負荷試験を手軽に実施できることです。詳しい内容は、発表の中で説明します。
負荷試験基盤をつくった理由
前提として、負荷試験基盤を作った理由からお話できればと思います。
DMM.com(以下:DMM)にはこれまで負荷試験をするためのエコシステムがなかったため、チーム間でノウハウの共有ができていませんでした。
各チームが同じようなインフラを一から用意し同じような便利ツールを実装している状況だったので、組織として開発効率が悪くなっていました。また、プロダクト開発で使用しているプログラミング言語と試験スクリプトを記述するプログラミング言語が異なることも多く、試験スクリプトを書くときの学習コストが高い状態でした。
加えて、負荷試験は変更頻度が多くありません。一度変更した後は半年以上の期間が空くことも少なくなく、その場合に以前実装した時のことを覚えていないため、基本的な知識を調べなおさなければならない...といったことが行われていました。
負荷試験基盤の要件
こうした課題感をもとに、負荷試験基盤をつくるにあたって3つの要件を決めました。
一つ目は、Goで試験スクリプトが書けることです。
これは負荷試験基盤をつくった理由でも説明した通り、各チームがそれぞれ独自の技術選定をしていると、チーム間でノウハウの共有ができません。負荷試験に限らず今までのDMMでは各チームで独自の技術選定をしていた課題があったので、組織戦略として開発言語にGoを採用しました。
開発言語をGoに統一することで、負荷試験に限らずさまざまな用途で共通の仕組みを作りチーム間のノウハウの共有を目指すことができます。そのため、試験スクリプトもGoで書けることは必須条件でした。
二つ目は、分散負荷試験ができることです。
負荷試験基盤を使うプロダクトは、DMMのさまざまなプロダクトから利用されることが多いため、負荷試験で要求される負荷も高くなります。単一のマシンでかけられる負荷では不足していたため、分散して負荷をかける必要がありました。
三つ目は、レポートを出力できることです。
DMMでは今まで負荷試験の結果の共有は重視されていなかったため、負荷試験の結果を社内で共有し、社内の誰でも見れるようにする必要がありました。社内で共有するために試験結果がレポートとして出力されるのは都合がよかったです。
加えて、各アプリケーションの性能をいつでも見れるようにする必要がありました。今までのDMMでは負荷試験の結果は失われがちで、アプリケーションの性能限界を知りたいときに過去の負荷試験の結果をすぐに出せない状態でした。そのため、各試験の結果をレポートとして出力し、共通の場所に保存しておく必要がありました。
負荷試験フレームワークの選定
そこで負荷試験基盤の要件に合うような負荷試験フレームワークを探したのですが、Goだけで試験スクリプトを書けるツールはありませんでした。(もしあったら教えてください。)
次に分散負荷試験が可能な負荷試験フレームワークとしては、Gatling、Vegetaなどが見つかりました。軽く調査した結果、Vegetaで試験スクリプトは書けなそうだと判断し、候補からは落としました。GatlingはScalaで試験スクリプトを書くので、こちらも候補には上がりませんでした。
最終的にLocustとBoomerを併用することに決めました。
Locustはご存知の方も多いと思いますが、Pythonで試験スクリプトが書けて、分散負荷をかけることができる負荷試験フレームワークです。
Locust Extentionsとは、Locustの一部の機能をPython以外のプログラミング言語を使って実装した拡張のことです。Locust Extentionsの例としてBoomerとLocust4jが紹介されています。BoomerはGoのライブラリです。
Boomerについて説明する前に、Locustの分散負荷処理の仕組みについて説明します。
Locustの分散負荷処理ではmasterとworkerの2種類のアプリケーションを立ち上げます。
masterは試験を管理するアプリケーションです。worker数の管理や試験終了後に試験結果のレポートの作成をします。workerは実際に試験を実施するアプリケーションです。worker数を増やすことで試験の負荷の量を増やすことができます。
次にBoomerの仕組みについて説明をします。
Locustのmasterとworkerの通信はmsgpackを使って行われています。msgpackを使いLocust workerのインタフェースを満たすように実装すれば、任意のプログラミング言語でLocust workerのように振る舞うアプリケーションを実装できます。
BoomerはGoでLocust workerのように振る舞うアプリケーションを実装するためのライブラリです。
Boomerの使い方は非常に簡単です。
負荷試験基盤で実際に使っているGoのコードを使って説明します。
Boomerを使うときは、まずBoomerのTask構造体の変数を宣言します。サンプルコードではTask構造体の変数が2つ宣言されていますが、1つでも大丈夫です。Task構造体には、実際に試験を行う処理の関数とweightをセットします。
試験を行う処理の関数はLocust実行時にセットされた並列数分のgoroutineでそれぞれ実行されます。weightをセットすることでtaskごとにどれくらいの割合で負荷をかけるか調整できます。
Task構造体の変数を宣言したら、BoomerのRun関数に試験で使うTask構造体の変数をセットし、Boomerを立ち上げます。
task関数では試験の成功、失敗を記録します。
試験実行後、Locust masterで記録を集計してレポートとして出力します。記録するために、taskが成功したらRecordSuccess関数を実行し失敗したらRecordFailureを実行します。このあたりの使い方はLocustと同じです。
Boomerを使う上で必要な項目は以上です。
フレームワークを使うときに3つくらいしか意識しなくてもよいため、試験スクリプトはほぼPure Goで記述できます。学習コストが低いところはBoomerの良いところだと思いました。
負荷試験基盤のアーキテクチャ
要件をもとに負荷試験基盤のアーキテクチャがどのような形になったのか、図を使って説明します。例として、試験を実施するユースケースを紹介します。
利用者はGitHub Actionsのworkflowをインタフェースにして負荷試験の開始、停止を操作します。
GitHub Actionsのworkflowはスライドの画像のようになっており、負荷試験基盤のコードを管理するGitHubリポジトリのタブから切り替えできるようになっています。
引数をセットしてworkflowを実行する画面は以下のようになっています。
GitHub Actionsで試験を実施すると、負荷試験用のアプリケーションがdocker buildされ、GCPのコンテナレジストリにイメージがpushされます。
これが終わったら、k8sに負荷試験のJobを作成しLocust masterとBoomerそれぞれのアプリケーションを立ち上げて負荷試験を開始します。
負荷試験実施中のログとトレースはDatadogに送られ、利用者はDatadogからログやトレース、メトリクスを確認できます。
試験が終了すると試験のレポートがGCSでホストされます。事業部のソフトウェアエンジニアであれば誰でも負荷試験結果を見ることができ、各試験のrpsやレイテンシを確認できます。
以下のようなレポートが出力されます。負荷試験のリクエスト数やレイテンシの平均値などを計算してくれます。画像からは見切れてますがグラフも用意されてます。
最後に、試験が終了したことをSlackで通知します。
以上が負荷試験基盤のアーキテクチャの紹介と負荷試験を開始するユースケースの説明です。
次に負荷試験基盤で利用している個々の仕組みについて説明します。
負荷試験基盤を支える仕組み
まず負荷試験基盤を支える仕組みとして、Goのテンプレートファイルの話をします。
負荷試験基盤ではテンプレートファイルを用意しています。そして、テンプレートファイルを試験ごとにコピーして、それを改造して使ってもらっています。
テンプレートファイルではBoomerのサンプルコード以外にも便利な仕組みを用意します。例えば、スライドのようにhttp.Clientのサンプルコードと使い方に関するコメントを実装しています。
負荷試験をするときにはhttp.Clientの設定値が重要ですが、Goのソースコードを読んでもどう使えばいいのかわからないときがあります。そのため、デフォルト値であっても明示的に設定値を書いておき、設定値に関する説明をコメントに書いておくことで、負荷試験でどのような値をセットすべきか参考になります。おまけにGoの理解度も高まります。
スライドだと、例えば、DisableKeepAlivesをtrueにセットしています。負荷試験をするときに稀に、「負荷はかけているけれどコネクションを使い回しているため実際のリクエストと異なる」という実装ミスが発生し、実施した負荷試験が正確でなくなってしまうことがあります。そのため、あらかじめDisableKeepAlivesをtrueにセットし、その説明をコメントに書いています。
今後の改善タスクとしては、http.Clientの構造体のフィールドで負荷試験に必要なものを洗い出して実装したり、Datadogのトレースログを細かく見るためにhttp.Clientに自作のRoundTripperをセットする、などがあります。
Goのテンプレートファイルでは、負荷試験で使う共通処理のライブラリを実装しています。例えばスライドの例では、アクセストークンを取得するライブラリのサンプルコードを載せています。
DMMでは負荷試験に限らず、各マイクロサービスが共通で使う仕組みがあります。その仕組みを使う処理をあらかじめライブラリ化しておくことで負荷試験を実施する各チームが自前で実装しなくてよくなるため、開発効率を向上させるメリットがあります。
Goからは少し離れてしまいますが、その他の仕組みについても紹介させてください。
以下はk8sマニフェストのテンプレートファイルです。負荷試験基盤では試験共通で利用するk8sマニフェストと、試験ごとに利用するk8sマニフェストの二種類を用意しています。kustomizeというコマンドラインツールを使って、共通設定と試験ごとの設定のk8sマニフェストを合成し試験ごとの環境の差分を吸収しています。
共通設定のk8sマニフェストは基本的に利用者には触らせないようにしており、GitHubリポジトリにコードオーナーを設定して負荷試験基盤の管理者の承認がなければ変更できないようにしています。
GitHub Actionsのworkflowは以下のようなかたちです。
一度試験スクリプトを作成してGitHubリポジトリにマージしてしまえば、workflowの実引数を変えて実行するだけでさまざまな条件の試験が同時に実施できます。
次に、試験終了後のSlack通知について説明します。試験が終了すると試験に関する情報をまとめてSlackで通知します。
いくつか通知項目はあるのですが、試験結果のURLは実装してみてとても便利だと思いました。Locustは試験が終了すると試験結果をまとめたレポートを出力してくれます。負荷試験基盤では、試験終了後にそのレポートをGCSでホストしてアカウントを持ってる人なら誰でも見れるようにしてます。そのため、毎回GCPにログインしてGCSのオブジェクトのURLを確認する必要はありません。
また、試験でエラーが発生した場合は、試験の失敗通知を送るようにしています。今は負荷試験側で細かいエラーハンドリングを実装できていないので、DatadogのログとトレースのURLをSlack通知に含めて利用者がエラー原因の調査をしやすくしてます。
これで負荷試験基盤の機能の説明は以上です。
負荷試験基盤の初期リリースで妥協したところ
次に、負荷試験基盤を作ってたときに妥協したところを紹介します。
一つ目は、Boomerからデフォルトで出力されるログがDatadogに送ったときにErrorログ判定される問題です。先ほど紹介したGoのテンプレートでは、ログライブラリのwrapper関数を提供しており、このwrapper関数は構造化ログを出力するのでこういった問題は発生しません。
しかし、Boomerのフレームワークの中で勝手に出力されるログがいくつかあり、そのログは構造化されていないためDatadog上でErrorログ判定されてしまいました。
当時はすぐに解決策を思いつかなかったため、Datadog Pipelineの「Category Processor」と「Status Remapper」を使ってInfoログに変換しました。
二つ目に、アプリケーション側で試験の開始・停止を管理するのが難しかったです。
試験の停止、再開を管理できるようにアプリケーション側で試験を停止させたかったのですが、綺麗な実装をすぐに思いつくことができませんでした。
負荷試験をうまく停止させることは必須要件ではなかったので、試験を管理することは諦め、試験を停止せずにJobを削除して試験を中断させる方法を選択しました。
三つ目に、試験の成功/失敗を検知することです。
アプリケーションレベル、Jobレベルで試験の成功/失敗を検知することが難しかったです。k8sの監視やアプリケーションレベルでエラーハンドリングすればやりたかったことを実現できそうでしたが、工数がかかりそうだったので諦めました。
妥協案として、Locustは試験が正常に終了するとレポートファイルを生成するのでJobのpreStopでレポートファイルの有無をチェックしています。「レポートファイルが存在していれば試験は成功、存在していなければ失敗」と判断するように実装しました。
しかし、この方法は直近で問題が発生しています。例えば、試験は失敗したがレポートファイルは生成されて成功扱いになるケースがあります。そのため、改善タスクとしてリリース後に絶賛修正中です。
最後にまとめです。
本発表では、Goで試験スクリプトを書けるようにするライブラリ、Boomerを紹介しました。テンプレートファイルを活用した負荷試験基盤の利用者をサポートする仕組みを紹介しました。今後は初期リリースで妥協したところを改善しながら、負荷試験するときにあったら便利な機能を実装していきます。
以上で発表を終わります。
質疑応答
発表の際に参加者から寄せられた質問について、回答内容とともに紹介します。
負荷試験はk8sで実行するということでしたが、負荷試験用のクラスターを用意しているのでしょうか? またクラスター外の環境への試験は可能でしょうか?
いっぬ: 負荷試験用のクラスターは以下の二つのいずれかを指しているかわからないので、両方について回答します。
- 負荷試験用のlocustのアプリケーションを動かすk8sクラスター
- 開発チームのアプリケーションが負荷試験をするためのk8sクラスター
負荷試験用のlocustのアプリケーションを動かすk8sクラスターは現在開発中です。
開発した理由は、インターネットを経由した負荷試験を実施するためです。負荷試験基盤の初期リリースではリリース速度を優先したため、アプリケーションのステージング環境のk8sクラスターで試験を実施することのみサポートしています。
開発チームのアプリケーションが負荷試験をするためのk8sクラスターを用意する予定はありません。
用意しない理由は必要性を感じていないからです。負荷試験用の隔離した環境を用意するのは、既存のステージング環境に影響を与えないためです。初期リリースでは実際に負荷試験をやってどの程度影響があるのかが不明なので、影響が出てから用意するかどうかを検討します。
別の観点として、負荷試験は本番環境相当の負荷を想定して実施するものなので、むしろステージング環境であっても負荷に耐えられるようにしておいたほうがいいです。プラットフォーム事業本部のステージング環境にデプロイされたプロダクトは他部署のプロダクトからも負荷をかけられるので、それに耐えられるようにしておけば、意図しない負荷がかかっても安定してステージング環境を提供できます。そのため、負荷試験用の隔離した環境を用意する必要はありません。
クラスター外の環境への試験は可能です。
DMMのプラットフォームではオンプレも利用しています。オンプレで動作するアプリケーションに負荷をかけることも可能ですが、現在アプリケーションをk8sに移行中なので、初期リリースでは試験対象をk8sで動作するアプリケーションに限定するように要件を絞っています。
locustで出力するレポートを共有するということですが、どういった用途のために共有するのでしょうか? 実際に他チームのレポートを閲覧することはあるのでしょうか?
いっぬ: 例えば、マイクロサービスで許容できるリクエスト数を見積もるときに使います。
DMMのプラットフォーム事業本部のプロダクトはマイクロサービス化されています。あるマイクロサービスで許容できるリクエスト数を見積もるときに、そのマイクロサービスで許容できるリクエスト数だけでなく、「依存先の」 マイクロサービスで許容できるリクエスト数も考慮しなければなりません。
そのときに、他チームのマイクロサービスの負荷試験のレポートを確認することがあります。
負荷試験基盤では本番環境、開発環境などとは別に負荷試験環境というものがあるのでしょうか? 開発環境で負荷試験をしてしまうと開発環境が動かなくなる可能性があるので、専用の環境が必要になると思うのですが
いっぬ: 今の負荷試験基盤ではDMMのステージング環境のみで実施できます。
DMMではステージング環境で問題なかったプロダクトを本番環境へリリースするフローをとっています。開発環境はステージング環境とは別に用意しているので、負荷試験によって開発環境が動かなくなるといったことはありません。
負荷試験の発表の中で、かける負荷の設定値を指定できるように見えましたが、1000RPSをかける、1000リクエストをかける、という指定はできるのでしょうか? できない場合はどのように調節するのでしょうか?
いっぬ: 負荷試験基盤ではRPSを指定することはできません。試験の並列数とk8sのPod数を指定できるので、利用者に試しに試験を動かしてもらって、期待するRPSが出るパラメーターを探してもらっています。
要件に合うかわからないですが、vegetaもGoで試験スクリプトが書けそうな気がしました!
いっぬ: ありがとうございます。発表でvegetaではGoで試験スクリプトを書けない、と言い切ってしまいましたが、調査不足だったかもしれません。