JMeterとJUnitとMavenで独自プロトコルサーバーの負荷テストを自動化するぞ
こんにちは、インフラストラクチャ本部の@nagaseyasuhitoです。このエントリは GREE Advent Calendar 2014 10日目の記事です。昨日はイケメンmoritaさんによる男性エンジニアリングマネージャが長期育休を取った話でした。
エンジニアブログのアカウントは2年くらい前からあるのですが、これが初エントリになります。グリーでは比較的珍しいJavaEEを始めとしたサーバーサイドJavaアプリケーションの開発、SolrやHadoopといったミドルウェアの周辺機能開発や運用などを行っています。どうぞよろしくお願いします。
最近はPvE/PvP/GvGなどユーザー同士がリアルタイムに協調プレイする際、クライアント-サーバー間を常時接続通信で行うゲームが増加しています。このような場合はHTTPのREST APIなど慣れ親しんだプロトコルでは要件を満たしきれないため、WebSocketやNettyをベースにThriftやProtocolBufferなどを使った独自プロトコルで通信をすることが多くなってきたのではないでしょうか。
HTTPベースのプロトコルであればWebブラウザやcurlなどのコマンドラインツールからでも動作確認できますしテストツールも充実していますが、独自プロトコルとなると一般的なツールも使えないため、どうしてもテストが手薄になりがちです。
しかも多人数で同時接続通信を行う場合、サーバー側でクライアント間の協調動作をさせる場合が多いため、マルチスレッドアクセスが原因のバグや、負荷が上がった場合にのみ現れるデータの不整合、効率の悪い同期化によって同時接続数が伸びないなどパフォーマンスの面にも注意を払わなければならないため、負荷テストは必須と言えます。
そこでこのエントリでは独自プロトコルサーバーに対する負荷テストを、JMeterとJUnitを使いMavenから複数のサーバーで分散実行できるようにしてCIに乗せる方法をご紹介します。
今回のサンプルはこちらのGitHubのリポジトリで公開しているのでぜひご自身の端末で実行してみてください。負荷テストがより身近に感じられると思います。
独自プロトコルの負荷試験
負荷テストツールと言えばJMeterやGatling、Grinderあたりが有名ですが今回はJMeterを使います。JMeterで独自プロトコルのテストと言うと「新規にプラグインを作るのかな」と思われるかもしれませんが違います。JMeterにはJUnitSamplerというその名の通りJUnitで書かれたテストを実行するサンプラーが標準で存在するのです。
つまり慣れ親しんだJUnitで結合テストを書くように負荷テストのシナリオを記述し、それをJMeterを使って実行するといういつも通りの開発に少しだけ手間をかけるだけで簡単に複数台のサーバーを使った負荷テストを実行できるのです。
Echoサーバーとクライアント
今回はWebSocketを使ったEchoサーバーを題材に取り上げます。JavaEEの話になるのでJMeterの話を早く読みたい方はこの章は飛ばしてもらって構いません。
JavaにはJSR356というWebSocketアプリケーションの仕様があるので、それに則って記述します。
サーバー実装
@ServerEndpointでエンドポイントのパスを指定し、@OnMessageの付加されたメソッドでクライアントから送信された文字列をログに出力した後、クライアントに送り返しています。
1 2 3 4 5 6 7 8 9 |
@Slf4j @ServerEndpoint("/echo") public class EchoServer { @OnMessage public String onMessage(String message) { log.info("Message Received: " + message); return message; } } |
クライアント実装
クライアントもサーバーと同じように記述できます。@OnOpenはサーバーと接続が確立した際に呼ばれるメソッドに付加するアノテーションです。NUMBER_OF_LOOPSで定義された回数だけメッセージを受信したら接続を閉じます。CountDownLatchはマルチスレッド環境下で安全にスレッド終了を待機させるための同期化支援機能です。CountDownLatch.countDown()メソッドを呼び出し、別スレッドに接続が閉じたことを伝えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@Getter @ClientEndpoint public class EchoClient { public static final int NUMBER_OF_LOOPS = 10; private List receivedMessages = new ArrayList<>(); private CountDownLatch latch = new CountDownLatch(1); @OnOpen public void onOpen(Session session) throws IOException { session.getBasicRemote().sendText(UUID.randomUUID().toString()); } @OnMessage public void onMessage(Session session, String message) throws IOException { this.receivedMessages.add(message); if (this.receivedMessages.size() == NUMBER_OF_LOOPS) { session.close(); this.latch.countDown(); } else { session.getBasicRemote().sendText(UUID.randomUUID().toString()); } } } |
結合テスト
それではこのサーバーとクライアントの結合テストを書いてみましょう。アプリケーションサーバーの結合テストはArquillianを使います。ArquillianはJUnitで書いたアプリケーションのテストをTomcatやJettyからGlassFish、Wildflyまで様々なアプリケーションサーバーを起動し、その上でテストを実行してくれるスグレモノです。
Arquillianはテストの際、アプリケーションをデプロイするコンテキストパスにUUIDを使うため実行する度にURLが異なってしまいます。そこで@RunAsClientアノテーションを使うと@ArquillianResourceアノテーションが付与されたjava.net.URL型のフィールドにデプロイ先のURLがインジェクトされます。結合テストではこのURLからWebSocketで接続するURIを作成します。
先ほどEchoClientに定義したCountDownLatchのawait()メソッドを呼ぶことで、内部的に保持しているカウンタが0になるまで処理をブロックします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@RunWith(Arquillian.class) @RunAsClient public class EchoEndpointIT { @ArquillianResource protected URL url; @Deployment public static Archive<?> createDeployment() { return ShrinkWrap.create(WebArchive.class).addClass(EchoServer.class); } @Test @SneakyThrows public void echoSuccess() { URI uri = new URI(this.url.toString().replaceAll("http://", "ws://") + "/echo").normalize(); WebSocketContainer container = ContainerProvider.getWebSocketContainer(); EchoClient client = new EchoClient(); container.connectToServer(client, uri); client.getLatch().await(); assertThat(client.getReceivedMessages().size(), is(EchoClient.NUMBER_OF_LOOPS)); } } |
このテストを実行するにはpom.xmlにmaven-failsafe-pluginを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 |
<plugin> <artifactId>maven-failsafe-plugin</artifactId> <version>2.18</version> <executions> <execution> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> </execution> </executions> </plugin> |
そして以下のコマンドのようにverifyフェーズまで実行すると結合テスト(デフォルトではクラス名の末尾がITのもの)が実行されます。
1 |
$ mvn clean verify |
実行すると以下のようなログが出力されるはずです。
1 2 3 4 5 6 |
2014-12-06 11:34:35 INFO EchoServer - Message Received: 2540bcd1-1c8b-437b-84fa-5bdeea5e0727 2014-12-06 11:34:35 INFO EchoServer - Message Received: 6546556b-b005-4af7-a396-bdfda4ecec72 2014-12-06 11:34:35 INFO EchoServer - Message Received: 9eccfeb7-2d0e-49b3-8984-da3779324443 2014-12-06 11:34:35 INFO EchoServer - Message Received: ff7bbed6-f1c3-4377-badc-d8fa17b9164d 2014-12-06 11:34:35 INFO EchoServer - Message Received: 8a3ec862-a5bc-4297-9539-3767907068db 2014-12-06 11:34:35 INFO EchoServer - Message Received: f7c0f8d3-b99d-4c17-9cf8-749c3245dd46 |
デプロイ
デプロイの方法はアプリケーションサーバーごとに様々ですが、Mavenからアプリケーションサーバーにデプロイするcargo-maven2-pluginを使ってみましょう。今回は例としてTomcat8にデプロイしてみます。最近のServletコンテナであればほとんどがJSR356に対応しているので他のアプリケーションサーバーで試して性能比較するのも面白いかもしれません。
先にTomcat8にcargo-maven2-pluginでデプロイするためのユーザーを作っておきましょう。$CATALINA_HOME/conf/tomcat-users.xmlに以下の記述を追加します。
1 2 3 4 |
<?xml version="1.0" encoding="utf-8"?> <tomcat-users> <user username="deployer" password="passwd" roles="manager-script" /> </tomcat-users> |
プラグインの定義はこのようにデプロイするコンテナのIDやデプロイ先のホストの情報を記述します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<plugin> <groupId>org.codehaus.cargo</groupId> <artifactId>cargo-maven2-plugin</artifactId> <version>1.4.10</version> <configuration> <container> <containerId>tomcat8x</containerId> <type>remote</type> </container> <configuration> <type>runtime</type> <properties> <cargo.hostname>${cargo.hostname}</cargo.hostname> <cargo.remote.username>${cargo.remote.username}</cargo.remote.username> <cargo.remote.password>${cargo.remote.password}</cargo.remote.password> </properties> </configuration> </configuration> </plugin> |
デプロイ先の情報は環境によって異なることが多いので、プロファイルに分割しておきます。
1 2 3 4 5 6 7 8 |
<profile> <id>development-environment</id> <properties> <cargo.hostname>localhost</cargo.hostname> <cargo.remote.username>deployer</cargo.remote.username> <cargo.remote.password>passwd</cargo.remote.password> </properties> </profile> |
そして以下のコマンドのように-Pオプションでプロファイルを指定してcargo:deployゴールを実行するとアプリケーションサーバーにデプロイされます。
1 |
$ mvn clean package cargo:deploy -Pdevelopment-environment |
ちなみにアンデプロイはcargo:undeploy、再デプロイはcargo:redeployを指定してください。
JUnitをJMeterで実行する
JUnitで記述された結合テストを負荷テストシナリオとしてJMeter上で実行してみましょう。テスト内容は結合テストと同じものを実行するので継承してしまいます。結合テストではテストランナーにArquillianを使っていましたが負荷テストではデフォルトのJUnit4に戻します。そして@Beforeで接続先のURLをプロパティファイルから読み込むように変更しました。
1 2 3 4 5 6 7 8 |
@RunWith(JUnit4.class) public class EchoEndpointST extends EchoEndpointIT { @Before @SneakyThrows public void before() { this.url = new URL(ResourceBundle.getBundle("stress-test").getString("url")); } } |
stress-test.propertiesは以下のようにしてMavenのプロパティ置換を利用します。
1 |
url=http://${cargo.hostname}:8080/${project.artifactId} |
GUIで実行
JUnitをJMeterで実行するには事前に関連するjarファイルを$JMETER_HOME/lib/junitにコピーする必要があります。必要なjarファイルは、
1 |
$ mvn clean test jar:jar jar:test-jar dependency:copy-dependencies |
でtargetディレクトリに生成されるのでそれをコピーしてください。
コピーができたらJMeterを起動して負荷テストシナリオを作成しましょう。Test Planの下にThread Groupを作成し、その下にJUnit Requestをぶら下げるシンプルな負荷テストシナリオです。必要があれば確認用のリスナーも作成しておきましょう。
JUnit Requestサンプラーの設定でClassnameとTest Methodに先ほど記述したテストクラスが表示されているはずです。この状態でテストを実行すると、
このように実行結果がリスナーに表示されていれば成功です。アプリケーションサーバーにもログが出力されているので確認しておきましょう。
リモートのサーバーで実行
複数台のサーバーを使ってJMeterの負荷テストシナリオを分散実行するには、それぞれのサーバーでjmeter-serverを起動しておく必要があります。確認だけなのでローカルホストでjmeter-serverを起動して試してみましょう。
jmeterコマンドに-nオプションをつけるとCUIで起動します。-tオプションは実行する負荷テストシナリオ、-Rオプションは分散実行するサーバーのホスト名です。複数台で実行する場合はカンマ区切りで指定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
$ jmeter-server & $ jmeter -n -t src/test/jmeter/com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jmx -R localhost Creating summariser <summary> Created the tree successfully using src/test/jmeter/com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jmx Configuring remote engine for localhost Using remote object: UnicastRef [liveRef: [endpoint:[192.168.1.1:60469](remote),objID:[1677e58d:14a1d8da86d:-7fff, -8443313415864415409]]] Starting remote engines Starting the test @ Sat Dec 06 12:04:44 JST 2014 (1417835084121) Remote engines have been started Waiting for possible shutdown message on port 4445 summary + 2 in 2s = 1.3/s Avg: 861 Min: 582 Max: 1141 Err: 0 (0.00%) Active: 0 Started: 10 Finished: 10 summary + 98 in 15.4s = 6.3/s Avg: 987 Min: 288 Max: 1644 Err: 0 (0.00%) Active: 0 Started: 10 Finished: 10 summary = 100 in 17s = 6.0/s Avg: 985 Min: 288 Max: 1644 Err: 0 (0.00%) Tidying up remote @ Sat Dec 06 12:05:02 JST 2014 (1417835102209) ... end of run |
各サーバー台数で負荷テストシナリオが実行されるので注意しましょう。例えば10スレッド10ループする負荷テストシナリオを10台に分散して実行すると合計10000回実行されます。
MavenからJMeterを実行できるようにする
ユニットテストや結合テスト、デプロイなどをMavenで自動化するなら負荷テストも自動化してしまいましょう。MavenにはJMeterを実行するjmeter-maven-pluginというこれまた便利なプラグインがあります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
<plugin> <groupId>com.lazerycode.jmeter</groupId> <artifactId>jmeter-maven-plugin</artifactId> <version>1.10.0</version> <executions> <execution> <id>jmeter-tests</id> <phase>integration-test</phase> <goals> <goal>jmeter</goal> </goals> </execution> </executions> <configuration> <testFilesDirectory>${project.build.testOutputDirectory}</testFilesDirectory> <testResultsTimestamp>false</testResultsTimestamp> <ignoreResultFailures>true</ignoreResultFailures> <suppressJMeterOutput>false</suppressJMeterOutput> <remoteConfig> <startServersBeforeTests>true</startServersBeforeTests> <serverList>${jmeter.servers}</serverList> <stopServersAfterTests>true</stopServersAfterTests> </remoteConfig> </configuration> </plugin> |
上記の設定でintegration-testフェーズでJMeterを実行します。設定はそれぞれ
- testFilesDirectory ... 負荷テストシナリオがあるディレクトリ
- testResultsTimestamp ... 結果ファイルにタイムスタンプを使うか
- ignoreResultFailures ... 負荷テストシナリオが失敗してもMavenの処理を続けるか
- suppressJMeterOutput ... JMeterの出力を抑制するか
- remoteConfig/startServersBeforeTests ... ちょっとよくわからないけどtrueにしておくと分散実行される
- remoteConfig/serverList ... 分散実行するサーバー
- remoteConfig/stopServersAfterTests ... 分散実行後jmeter-serverを終了するか
これだけで設定終わり!になればとても楽なのですが、先述した通りjarファイルを分散実行するサーバーにコピーしてjmeter-serverを起動しておく必要があります。さすがにそんなプラグインは無いのでmaven-exec-pluginでシェルスクリプトを実行します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#!/bin/sh IFS="," SERVERS="${jmeter.servers}" DESTINATION="${jmeter.home}/lib/junit" for SERVER in $SERVERS; do HOST="${jmeter.user}@$SERVER" ssh $HOST "rm -rf $DESTINATION/*" scp -r ${project.build.directory}/dependency/* $HOST:$DESTINATION scp ${project.build.directory}/${project.build.finalName}.jar $HOST:$DESTINATION scp ${project.build.directory}/${project.build.finalName}-tests.jar $HOST:$DESTINATION ssh $HOST "nohup ${jmeter.home}/bin/jmeter-server > ${jmeter.home}/stdout.log 2> ${jmeter.home}/stderr.log < /dev/null &" done |
これもMavenのプロパティ置換でscp先などを指定できるようにします。また負荷テストシナリオのThread Groupの設定もMaven実行時に変更できるようにプロパティにしてしまいましょう。
1 2 3 4 5 6 7 8 9 |
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Thread Group" enabled="true"> <stringProp name="ThreadGroup.on_sample_error">continue</stringProp> <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true"> <boolProp name="LoopController.continue_forever">false</boolProp> <stringProp name="LoopController.loops">${jmeter.loopCount}</stringProp> </elementProp> <stringProp name="ThreadGroup.num_threads">${jmeter.numberOfThreads}</stringProp> <stringProp name="ThreadGroup.ramp_time">${jmeter.rampUpPeriod}</stringProp> </ThreadGroup> |
こうしておくとMaven実行時に
1 |
$ mvn clean verify -Djmeter.numberOfThreads=7000 -Djmeter.loopCount=1 -Djmeter.rampUpPeriod=60 |
のように負荷を調整しながら実行できます。5台のサーバーをjmeter-server用に準備して上記のコマンドを実行てみました。7000スレッド×5台を60秒で起動するシナリオです。しばらくすると、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
[INFO] --- jmeter-maven-plugin:1.10.0:jmeter (default) @ sample-jmeter --- [INFO] [INFO] ------------------------------------------------------- [INFO] P E R F O R M A N C E T E S T S [INFO] ------------------------------------------------------- [INFO] [INFO] [info] [debug] JMeter is called with the following command line arguments: -n -t /home/nagaseyasuhito/sample-jmeter/target/test-classes/com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jmx -l /home/nagaseyasuhito/sample-jmeter/target/jmeter/results/com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jtl -d /home/nagaseyasuhito/sample-jmeter/target/jmeter -j /home/nagaseyasuhito/sample-jmeter/target/jmeter/logs/com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jmx.log -r -R 192.168.1.1,192.168.1.2,192.168.1.3,192.168.1.4,192.168.1.5 -X -Dsun.net.http.allowRestrictedHeaders true [info] Executing test: com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jmx [info] Creating summariser <summary> [info] Created the tree successfully using /home/nagaseyasuhito/sample-jmeter/target/test-classes/com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jmx [info] Configuring remote engine for 192.168.1.1 [info] Using remote object: UnicastRef [liveRef: [endpoint:[192.168.1.1:41991](remote),objID:[-56618c96:14a2a6931ba:-7fff, 6093428288341961798]]] [info] Configuring remote engine for 192.168.1.2 [info] Using remote object: UnicastRef [liveRef: [endpoint:[192.168.1.2:51855](remote),objID:[217ab7d0:14a2a6987ea:-7fff, -6442079714823894686]]] [info] Configuring remote engine for 192.168.1.3 [info] Using remote object: UnicastRef [liveRef: [endpoint:[192.168.1.3:56093](remote),objID:[-50066af2:14a2a698a83:-7fff, 1526694257218326723]]] [info] Configuring remote engine for 192.168.1.4 [info] Using remote object: UnicastRef [liveRef: [endpoint:[192.168.1.4:57981](remote),objID:[19b2eb4a:14a2a698d43:-7fff, 6486487486601975473]]] [info] Configuring remote engine for 192.168.1.5 [info] Using remote object: UnicastRef [liveRef: [endpoint:[192.168.1.5:52170](remote),objID:[-545dac85:14a2a698ffb:-7fff, 5608046978234053081]]] [info] Starting remote engines [info] Starting the test @ Mon Dec 08 23:59:33 JST 2014 (1418050773240) [info] Remote engines have been started [info] Waiting for possible shutdown message on port 4445 [info] summary + 1 in 1s = 1.4/s Avg: 705 Min: 705 Max: 705 Err: 0 (0.00%) Active: 360 Started: 329 Finished: 21 [info] summary + 13605 in 26s = 530.0/s Avg: 176 Min: 2 Max: 6501 Err: 0 (0.00%) Active: 3 Started: 13857 Finished: 13906 [info] summary = 13606 in 26s = 530.0/s Avg: 176 Min: 2 Max: 6501 Err: 0 (0.00%) [info] summary + 16100 in 31s = 527.6/s Avg: 3 Min: 2 Max: 89 Err: 0 (0.00%) Active: 2 Started: 29966 Finished: 30016 [info] summary = 29706 in 56s = 534.8/s Avg: 82 Min: 2 Max: 6501 Err: 0 (0.00%) [info] summary + 5294 in 12s = 449.2/s Avg: 2 Min: 2 Max: 29 Err: 0 (0.00%) Active: 0 Started: 34948 Finished: 35000 [info] summary = 35000 in 67s = 525.1/s Avg: 70 Min: 2 Max: 6501 Err: 0 (0.00%) [info] Tidying up remote @ Tue Dec 09 00:00:40 JST 2014 (1418050840917) [info] Exitting remote servers [info] ... end of run [info] Completed Test: com.github.nagaseyasuhito.sample.jmeter.EchoEndpointST.jmx [INFO] [INFO] Test Results: [INFO] [INFO] Tests Run: 1, Failures: 0 |
このようにサマリーが表示されます。JMeterの実行結果はファイルにも保存されているため、JMeterで開いてグラフ化する事もできます。
Jenkinsでレポート出力する
JenkinsにPerformance PluginをインストールするとJMeterの実行結果をJenkins上でグラフ化できます。
プラグインをインストールするとPost-build ActionにPublish Performance test result reportという項目が追加されます。Add a new reportよりJMeterを選択してReport filesに**/*.jtlを設定しておけば、Jenkinsから負荷テストを実行し、結果を閲覧するところまで1-clickで行えるようになるので負荷テストを継続的に行うことができます。
おまけ JMeterのプロパティをJUnitに渡すには
JUnitのテストにJMeterのプロパティを渡したい場合、JUnitSamplerのConstructor String Labelという機能を使います。JUnitのテストにStringの引数をとるコンストラクタを定義すると、負荷テストシナリオのConstructor String Labelフィールドにその値が渡されます。JMeterのプロパティ置換も行われるのでループの回数やスレッドのIDなどもJUnit側で扱うことができます。
おまけ FlightRecoderで調査
負荷テストの際どこがボトルネックになっているか調査するためにアプリケーションのプロファイリングは欠かせません。最近のJavaで標準装備になったFlightRecorderを使う場合、Tomcat8であれば$CATALINA_HOME/bin/setenv.shに
1 |
JAVA_OPTS="-XX:+UnlockCommercialFeatures -XX:+FlightRecorder -XX:FlightRecorderOptions=defaultrecording=true,dumponexit=true,dumponexitpath=/home/nagaseyasuhito/sample-jmeter.jfr" |
と記述しておくとTomcat8を終了した際にプロファイルの結果がdumponexitpathで指定したパスに保存されるので、手元のマシンのJava Mission Controlで開いてボトルネックの調査をするといいでしょう。
まとめ
いかがだったでしょうか。本番環境に近い十分な負荷がかけられるようになって初めて負荷テストのスタートラインに立ったようなものです。アプリケーションだけではなく、カーネルやネットワークを含めたチューニングこそエンジニアの腕の見せどころですね。
十分な負荷テストをせず本番運用が始まってから過負荷によるサービス停止といった不幸な事件を起こさないため、このエントリをきっかけに本番環境に近い負荷をかけたテストがより身近なものになり、皆様のサービス品質の向上につながれば幸いです。
明日はネットワークのスペシャリスト、黒河内さんのたぶんなんかネットワーク系の話です!