低コストかつ汎用的な負荷試験手法
(JMeterを機能拡張してみる)

【技業LOG】技術者が紹介するNTTPCのテクノロジー

2014.05.20
その他
古賀 紳一郎

ソフトウェアエンジニア(ネットワーク、Webシステム等)
古賀 紳一郎

取得資格:ITIL version3 Foundation / Ruby Association Certified Ruby Programmer Gold

技業LOG

ネットワークやサーバーの負荷試験を行う場合、それを専門とした装置やパッケージソフトウェアがあります。しかし、これらは機能が豊富な分、高価であるため、オーバースペックとなる場合があります。今回はJMeterを使って低コストかつ汎用的な負荷試験を実現する方法について解説を行います。

1. JMeterの概要

JMeterは負荷試験を行うためのJavaアプリケーションです。ライセンスはApacheLicense2.0のため無償で使用することができます。HTTPリクエストを多数送信して負荷をかけるのが主な機能ですが、HTTP以外にもFTPやLDAPにも対応しています。

主にWebアプリケーションの負荷試験に使用されますが、次のいずれかの方法により汎用的な負荷試験を実現できます。

  • OS Process Samplerを使用する。
  • JMeterを機能拡張する。

OS Process SamplerはOSのコマンドを実行できるサンプラーです。つまり、予めスクリプトなどで負荷試験の処理を実装しておけばJMeterで実行することができます。簡単に実装できる反面、前処理や後処理に対応していないなどの制限があります。複雑な独自処理がある場合はJMeterの機能拡張が良いでしょう。

2. JMeterの機能拡張

JMeterのサンプラーはJMeter APIを使用して実装されていてcore部分とは分離されています。つまり、JMeter APIを使用すればJmeterのソースコードに手を入れずに独自機能を追加することができます。

本記事では例として次の仕様でJMeterの機能拡張の方法を示します。

  • 前処理
    • データファイルを作成する
    • データサイズを指定できる(KiB単位)
    • データファイルはddコマンドで作成し、/dev/urandomを使用して内容を毎回ランダムにする
  • サンプラーの処理
    • rsyncで「ファイルの転送」と「転送したファイルを転送先から削除」を実行する
    • rsyncのパラメータとして次の設定ができる
      • 転送先の認証で使用するユーザ名
      • 転送先のホスト名
      • 転送先のディレクトリ(ファイルの保存場所)
      • 転送先の認証で使用するSSHの秘密鍵(予め転送先では公開鍵を登録しておきます)

機能拡張の大まかな流れは次の通りです。

  1. 依存ライブラリの準備
  2. 前処理の設定を行うGUIの実装(RsyncPreProcessorGui)
  3. 前処理の実装(RsyncPreProcessor)
  4. サンプラーの設定を行うGUIの実装(RsyncSamplerGui)
  5. サンプラーの実装(RsyncSampler)
  6. JARを作成してJMeterに配置

3. 依存ライブラリの準備

機能拡張に必要なライブラリはApacheJMeter_coreです。JMeterのパッケージにも含まれていますし、Mavenのリポジトリにもあります。Gradleのようなビルドツールを使用するのが良いでしょう。

Gradleの場合、build.gradleは次のような設定になります。

apply plugin: 'java'
sourceCompatibility = 1.7
version             = '1.0.0'
repositories {
    mavenCentral()
}
dependencies {
    compile 'org.apache.jmeter:ApacheJMeter_core:2.10'
}

4. 前処理の設定を行うGUIの実装

前処理の設定を行うGUIのクラスではAbstractPreProcessorGuiを継承します。

実装内容を大まかに説明すると次のようになります。

  • コンストラクタでGUI上に表示するコンポーネントを配置
  • createTestElementメソッドをオーバライドしてRsyncPreProcessorのオブジェクトを生成
  • modifyTestElementメソッドをオーバライドしてGUI上で入力された値をプロパティとして設定 (RsyncPreProcessorでgetPropertyメソッドを呼び出すことにより値を取得できるようにする)
package jp.co.nttpc.protocol.rsync.processor.gui;
import java.awt.BorderLayout;
import jp.co.nttpc.protocol.rsync.processor.RsyncPreProcessor;
import org.apache.jmeter.gui.util.VerticalPanel;
import org.apache.jmeter.processor.gui.AbstractPreProcessorGui;
import org.apache.jmeter.testelement.TestElement;
import org.apache.jorphan.gui.JLabeledTextField;
public class RsyncPreProcessorGui extends AbstractPreProcessorGui {
    private static final long serialVersionUID = -6661607838080656590L;
    private JLabeledTextField dataSize;
    public RsyncPreProcessorGui() {
        // データサイズを設定するためのテキストフィールド
        dataSize = new JLabeledTextField("data size(KiB): ");
        // コンポーネントの配置
        setLayout(new BorderLayout(0, 5));
        setBorder(makeBorder());
        add(makeTitlePanel(), BorderLayout.NORTH);
        VerticalPanel mainPanel = new VerticalPanel();
        mainPanel.add(dataSize);
        add(mainPanel, BorderLayout.CENTER);
    }
    @Override
    public String getLabelResource() {
      return null;
    }
    public String getStaticLabel() {
        return "Rsync PreProcessor";
    }
    // JMeter上でこの前処理が追加された時に呼び出される。
    @Override
    public TestElement createTestElement() {
        // 前処理クラスのオブジェクトを生成
        RsyncPreProcessor preProcessor = new RsyncPreProcessor();
        modifyTestElement(preProcessor);
        return preProcessor;
    }
    // GUIの入力値が更新された時に呼び出される。
    @Override
    public void modifyTestElement(TestElement testElement) {
        testElement.clear();
        configureTestElement(testElement);
        // フィールドに入力されているデータサイズを前処理で使用できるようにする。
        testElement.setProperty("dataSize", dataSize.getText());
    }
    // 必須ではないがこのメソッドを実装しておくと再起動時に
    // 保存している設定ファイルから設定値を引き継げる。
    public void configure(TestElement testElement) {
        dataSize.setText(testElement.getPropertyAsString("dataSize"));
        super.configure(testElement);
    }
}

5. 前処理の実装

前処理のクラスではAbstractTestElementクラスを継承し、PreProcessorインタフェースを実装します。processメソッドをオーバライドし、このメソッドで前処理の段階で行う処理を実装します。この例ではddコマンドを実行しています。

package jp.co.nttpc.protocol.rsync.processor;
import java.io.File;
import org.apache.jmeter.processor.PreProcessor;
import org.apache.jmeter.testelement.AbstractTestElement;
import org.apache.jorphan.logging.LoggingManager;
import org.apache.log.Logger;
public class RsyncPreProcessor extends AbstractTestElement implements PreProcessor {
    private static final long serialVersionUID = 1023768394124783395L;
    private final Logger log = LoggingManager.getLoggerForClass();
    public static final String DATA_DIR  = "/tmp/jmeter";
    public static final String DATA_FILE = DATA_DIR + "/test.dat";
    public static final String EMPTY_DIR = DATA_DIR + "/empty/";
    @Override
    public void process() {
        // ファイルを作成するコマンド
        String[] command = {
                "dd",
                "if=/dev/urandom",   // 内容はランダム
                "of=" + DATA_FILE,
                "bs=1024",
                "count=" + getProperty("dataSize"), // GUIで入力したデータサイズ
        };
        File emptyDir = new File(EMPTY_DIR);
        try {
            // 必要なディレクトリを作成
            emptyDir.mkdirs();
            // ファイル作成を実行
            Runtime runtime = Runtime.getRuntime();
            Process process = runtime.exec(command);
            process.waitFor();
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }
}

6. サンプラーの設定を行うGUIの実装

サンプラーの設定を行うGUIのクラスではAbstractSamplerGuiを継承します。

実装内容を大まかに説明すると次のようになります。

  • コンストラクタでGUI上に表示するコンポーネントを配置
  • createTestElementメソッドをオーバライドしてRsyncSamplerのオブジェクトを生成
  • modifyTestElementメソッドをオーバライドしてGUI上で入力された値をプロパティとして設定(RsyncSamplerでgetPropertyメソッドを呼び出すことにより値を取得できるようにする)
package jp.co.nttpc.protocol.rsync.sampler.gui;
import java.awt.BorderLayout;
import java.awt.Component;
import java.io.File;
import javax.swing.BorderFactory;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JPanel;
import jp.co.nttpc.protocol.rsync.sampler.RsyncSampler;
import org.apache.jmeter.gui.util.VerticalPanel;
import org.apache.jmeter.samplers.gui.AbstractSamplerGui;
import org.apache.jmeter.testelement.TestElement;
import org.apache.jorphan.gui.JLabeledTextField;
public class RsyncSamplerGui extends AbstractSamplerGui {
    private static final long serialVersionUID = 8809671080852446503L;
    private JFileChooser sshKey;
    private JLabeledTextField destinationUser;
    private JLabeledTextField destinationHost;
    private JLabeledTextField destinationPath;
    public RsyncSamplerGui() {
        // コンポーネントの配置
        setLayout(new BorderLayout(0, 5));
        setBorder(makeBorder());
        add(makeTitlePanel(), BorderLayout.NORTH);
        VerticalPanel mainPanel = new VerticalPanel();
        mainPanel.add(createDestinationPanel());
        mainPanel.add(createSSHPanel());
        add(mainPanel, BorderLayout.CENTER);
    }
    private JPanel createPanel(String labelText, Component component) {
        JPanel panel = new JPanel(new BorderLayout(5, 0));
        JLabel label = new JLabel(labelText);
        label.setLabelFor(component);
        panel.add(label, BorderLayout.WEST);
        panel.add(component, BorderLayout.CENTER);
        return panel;
    }
    private Component createDestinationPanel() {
        // 転送先の認証で使用するユーザ名を設定するためのテキストフィールド
        destinationUser = new JLabeledTextField("user: ");
        // 転送先のホスト名を設定するためのテキストフィールド
        destinationHost = new JLabeledTextField("host: ");
        // 転送先のディレクトリ(ファイルの保存場所)を設定するためのテキストフィールド
        destinationPath = new JLabeledTextField("path: ");
        JPanel dataPanel = new VerticalPanel();
        dataPanel.setBorder(BorderFactory.createTitledBorder("destination"));
        dataPanel.add(destinationUser);
        dataPanel.add(destinationHost);
        dataPanel.add(destinationPath);
        return dataPanel;
    }
    private Component createSSHPanel() {
        // 転送先の認証で使用するSSHの秘密鍵を設定するためのファイル選択コンポーネント
        sshKey = new JFileChooser();
        sshKey.setFileHidingEnabled(false);
        sshKey.setControlButtonsAreShown(false);
        JPanel dataPanel = new VerticalPanel();
        dataPanel.setBorder(BorderFactory.createTitledBorder("SSH"));
        dataPanel.add(createPanel("key: ", sshKey));
        return dataPanel;
    }
    @Override
    public String getLabelResource() {
        return null;
    }
    public String getStaticLabel() {
        return "Rsync Sampler";
    }
    // JMeter上でこのサンプラーが追加された時に呼び出される。
    @Override
    public TestElement createTestElement() {
        // サンプラークラスのオブジェクトを生成
        RsyncSampler sampler = new RsyncSampler();
        modifyTestElement(sampler);
        return sampler;
    }
    // GUIの入力値が更新された時に呼び出される。
    @Override
    public void modifyTestElement(TestElement testElement) {
        testElement.clear();
        configureTestElement(testElement);
        // 入力されている値をサンプラーで使用できるようにする。
        testElement.setProperty("destinationUser", destinationUser.getText());
        testElement.setProperty("destinationHost", destinationHost.getText());
        testElement.setProperty("destinationPath", destinationPath.getText());
        if (sshKey.getSelectedFile() != null) {
          testElement.setProperty("sshKey", sshKey.getSelectedFile().getPath());
        }
    }
    // 必須ではないがこのメソッドを実装しておくと再起動時に
    // 保存している設定ファイルから設定値を引き継げる。
    public void configure(TestElement testElement) {
        destinationUser.setText(testElement.getPropertyAsString("destinationUser"));
        destinationHost.setText(testElement.getPropertyAsString("destinationHost"));
        destinationPath.setText(testElement.getPropertyAsString("destinationPath"));
        sshKey.setSelectedFile(new File(testElement.getPropertyAsString("sshKey")));
        super.configure(testElement);
    }
}

7. サンプラーの実装

サンプラーのクラスではAbstractSamplerを継承します。

sampleメソッドをオーバライドし、このメソッドで負荷試験で行う処理を実装します。この例ではrsyncコマンドを実行しています。

package jp.co.nttpc.protocol.rsync.sampler;
import jp.co.nttpc.protocol.rsync.processor.RsyncPreProcessor;
import org.apache.jmeter.samplers.AbstractSampler;
import org.apache.jmeter.samplers.Entry;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jorphan.logging.LoggingManager;
import org.apache.log.Logger;
public class RsyncSampler extends AbstractSampler {
    private static final long serialVersionUID = -6054273328382720281L;
    private final Logger log = LoggingManager.getLoggerForClass();
    // rsyncに渡す転送先の情報の文字列を作成
    private String createDestination() {
        StringBuffer destination = new StringBuffer(128);
        // GUIで入力した転送先のユーザ名
        destination.append(getProperty("destinationUser"));
        destination.append("@");
        // GUIで入力した転送先のホスト名
        destination.append(getProperty("destinationHost"));
        destination.append(":");
        // GUIで入力した転送先のディレクトリ
        destination.append(getProperty("destinationPath"));
        destination.append("/");
        // スレッド毎に違うディレクトリにファイルを転送するようにスレッドIDをパスに追加
        destination.append(Thread.currentThread().getId());
        destination.append("/");
        return destination.toString();
    }
    // rsyncコマンドを作成
    private String[] createCommand(String src, String dest) {
        return new String[] {
                "rsync",
                "-avz",
                "--delete",
                "-e",
                "ssh -i " + getProperty("sshKey"),   // GUIで入力したSSHの秘密鍵
                src,
                dest,
        };
    }
    @Override
    public SampleResult sample(Entry entry) {
        String destination = createDestination();
        // ファイル転送のコマンドを作成。
        String[] sendFileCommand =
                createCommand(RsyncPreProcessor.DATA_FILE, destination);
        // ファイル削除のコマンドを作成。
        // 空のディレクトリをsyncすることで転送したファイルを削除する。
        String[] deleteFileCommand =
                createCommand(RsyncPreProcessor.EMPTY_DIR, destination);
        SampleResult result = new SampleResult();
        result.sampleStart();
        try {
            Runtime runtime = Runtime.getRuntime();
            Process process;
            // ファイル転送
            process = runtime.exec(sendFileCommand);
            process.waitFor();
            // ファイル削除
            process = runtime.exec(deleteFileCommand);
            process.waitFor();
            result.setSuccessful(true);
        } catch (Exception e) {
            log.error(e.getMessage());
            result.setSuccessful(false);
        }
        result.sampleEnd();
        return result;
    }
}

8. JARを作成してJMeterに配置

JARを作成してJMeterに配置します。

Gradleの場合は次のようにJARを作成します。

$ gradle jar

JMeterを解凍したディレクトリ内にあるlib/extディレクトリに作成したJARを配置するだけで動作します。解凍したJMeterが/Users/koga/Applications/apache-jmeter-2.11であるとすると次のように配置します。

$ cp rsync-sampler-1.0.0.jar /Users/koga/Applications/apache-jmeter-2.11/lib/ext

9. 動作させてみる

本記事では例としてテスト計画に次の項目を追加しています。

  • 今回作成した前処理(Rsync PreProcessor)
  • スレッドグループ
  • 今回作成したサンプラー(Rsync Sampler)
  • グラフ表示

9.1 前処理の設定画面

データサイズを入力します(画面例では3KiB)。

9.1 前処理の設定画面

9.2 スレッドグループの設定画面

スレッド数やループ回数を入力します(画面例ではスレッド数10、ループ回数100)。

9.2 スレッドグループの設定画面

9.3 サンプラーの設定画面

次の項目を入力します。

  • 転送先の認証で使用するユーザ名(画面例ではtest001)
  • 転送先のホスト名(画面例では10.0.0.1)
  • 転送先のディレクトリ(ファイルの保存場所)(画面例では/tmp/jmeter)
  • 転送先の認証で使用するSSHの秘密鍵(予め転送先では公開鍵を登録しておきます)(画面例では~/.ssh/id_rsa)
9.3 サンプラーの設定画面

9.4 グラフ表示

スレッド数10、ループ回数100で実行した結果をグラフ表示してみました。

9.4 グラフ表示

9.5 転送先の様子

スレッドの数だけディレクトリが作成され、ファイルが転送されていることがわかります。サンプラーの処理でファイル削除も実行しているのでファイルが存在するディレクトリと存在しないディレクトリがあります。

9.5 転送先の様子

10. まとめ

今回はJMeterを機能拡張して任意の負荷試験を実行する方法について解説しました。GUIの実装がやや面倒ですが、市販製品を使うよりはコストが抑えられるはずです。また、Javaで実装可能なことは実現できるため幅広い用途で使用できます。「少々手間がかかっても低コストで負荷試験したい」、「JMeterのデフォルトの機能では物足りない」という場面で参考になれば幸いです。

おすすめ記事

    お気軽にご相談ください