寝て起きて寝る

過ぎたるは及ばざるが如し

Seleniumで画像比較を活用したUI自動テスト

はじめに

この記事は、UL Systems Advent Calendar 2020 - 19日目の転記です。

システム開発の最終段階ではUIからバックエンドロジックまでを含めて期待通りの結果になっているか確認をE2Eテストで行うと思います。 ただE2Eテストでは人手による操作を行い目視での確認を行うケースが多いのではないでしょうか。そうした場合、一部のロジックを修正し再度テストを行う場合も自動で行う事は出来ません。 また、Seleniumを使用したテストではE2Eテストを自動で行う事はできますがテストコード作成に対する工数がかかり採用されないケースもあります。

そこで、画像比較を用いる事でテストコード作成の工数を削減出来るのでは無いかと思いSeleniumと画像比較を使用したUIテストをご紹介いたします。

今回使用するImageComparisonは、画像の比較を行うライブラリです。

ImageComparisonとは

同じサイズの2つの画像を比較し、異なる部分に長方形を描画することで違いを視覚的に示すことが出来るライブラリです。

ライブラリのプロパティ説明

ライブラリを使用して指定可能なプロパティです。

プロパティ 説明
threshold 等しくないと判断するピクセル間の最大距離の閾値(デフォルト:5)
rectangleLineWidth 長方形の線幅(デフォルト:1)
destination 比較結果ファイルの保存先
minimalRectangleSize 最小長方形サイズ(デフォルト:1)
maximalRectangleCount 描画される長方形の最大数(デフォルト:-1[制限無])
pixelToleranceLevel ピクセル許容レベル(デフォルト:0.1[10%])
excludedAreas 画像を比較するときに無視されるリスト
drawExcludedRectangles 除外された長方形を描くの可否
fillExcludedRectangles 除外された長方形を塗りつぶすかの可否
percentOpacityExcludedRectangles 除外された長方形の不透明度
fillDifferenceRectangles 差分長方形の可否
percentOpacityDifferenceRectangles 差分長方形の不透明度
allowingPercentOfDifferentPixels 無視されるピクセル割合(デフォルト:-1[制限無])

Seleniumを使用した例

今回はSeleniumを使用しGoogleウルシステムズ株式会社と検索した検索結果画面とウルシステムズと検索した検索結果画面の比較を行う自動テストを行います。テスト結果がfalseの場合は、異なる箇所が長方形で囲まれた比較画像を生成します。

現状の画面キャプチャを取得する

まず始めに比較元となる画面キャプチャ画像を取得します。 Seleniumを使用しchromeを起動後にGoogleウルシステムズ株式会社と検索し検索結果を表示した画面のキャプチャ画像を保存します。

public class ExpectedImage {
    public static void main(String[] args) throws InterruptedException {
        // Chrome
        System.setProperty("webdriver.chrome.driver", "selenium/webdriver/chrome/87.0.4280.88/win32/chromedriver.exe");
        // Chrome 起動オプションを構成
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless");
        WebDriver driver = new ChromeDriver(options);

        driver.get("https://www.google.co.jp/");
        WebElement element = driver.findElement(By.name("q"));
        element.sendKeys("ウルシステムズ株式会社");
        element.submit();
        driver.manage().timeouts().pageLoadTimeout(15, TimeUnit.SECONDS);
        Thread.sleep(5000L);
        driver.manage().window().setSize(new Dimension(Integer.parseInt("1500"), Integer.parseInt("3000")));
        File screenFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
        Files.copy(screenFile.toPath(), Paths.get("src/test/resources/screenshot/expected/ウルシステムズ株式会社.png"), StandardCopyOption.REPLACE_EXISTING);

        driver.quit();
    }
}

比較元の検索結果画面

比較元となる画面キャプチャ画像です。 search_uls.png

検索結果のUI自動テスト

SeleniumとImageComparisonを使用してUI自動テストコードを書き比較元の画面キャプチャ画像と比較を行います。比較結果が異なる場合は異なる部分が長方形で覆われた画像が出力されます。

今回はSeleniumを使用しchromeを起動後にGoogleウルシステムズと検索し検索結果画面の画像比較を行いたいと思います。

public class SearchULSTest {
    private WebDriver driver;

    @Before
    public void setUp() {
        // Chrome
        System.setProperty("webdriver.chrome.driver", "selenium/webdriver/chrome/87.0.4280.88/win32/chromedriver.exe");
        // Chrome 起動オプションを構成
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless");
        this.driver = new ChromeDriver(options);
    }

    @After
    public void closeDriver() {
        this.driver.quit();
    }

    @Test
    public void test0001() throws IOException, InterruptedException {
        driver.get("https://www.google.co.jp/");
        WebElement element = driver.findElement(By.name("q"));
        element.sendKeys("ウルシステムズ");
        element.submit();
        driver.manage().timeouts().pageLoadTimeout(15, TimeUnit.SECONDS);
        Thread.sleep(5000L);
        driver.manage().window().setSize(new Dimension(Integer.parseInt("1500"), Integer.parseInt("3000")));

        File screenFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
        Files.copy(screenFile.toPath(), Paths.get("src/test/resources/screenshot/actual/ウルシステムズ.png"), StandardCopyOption.REPLACE_EXISTING);
        Thread.sleep(5000L);

        boolean result = compareImage("screenshot/expected/ウルシステムズ株式会社.png", "screenshot/actual/ウルシステムズ.png");
        Assert.assertTrue(result);
    }

    private static boolean compareImage(String expected, String actual) {
        // 比較する画像のロード
        BufferedImage expectedImage = ImageComparisonUtil.readImageFromResources(expected);
        BufferedImage actualImage = ImageComparisonUtil.readImageFromResources(actual);
        // 比較結果ファイルの保存先
        File resultDestination = new File("compareimage.png");
        // 画像比較オブジェクトの生成
        ImageComparison imageComparison = new ImageComparison(expectedImage, actualImage, resultDestination);

        // 画像比較を判断するピクセル間の最大距離
        imageComparison.setThreshold(10);
        // 差分長方形の線幅
        imageComparison.setRectangleLineWidth(2);
        // 差分長方形の内側を塗りつぶすかの指定と透明度
        imageComparison.setDifferenceRectangleFilling(true, 10.0);
        // 除外長方形の内側を塗りつぶすかの指定と透明度
        imageComparison.setExcludedRectangleFilling(true, 10.0);
        // 描画される長方形の最大数
        imageComparison.setMaximalRectangleCount(100);
        // 最小長方形サイズ
        imageComparison.setMinimalRectangleSize(10);
        // ピクセル許容レベル
        imageComparison.setPixelToleranceLevel(0.2);

        // 画像の比較
        ImageComparisonResult imageComparisonResult = imageComparison.compareImages();
        if (ImageComparisonState.MATCH == imageComparisonResult.getImageComparisonState()) return true;

        // 比較結果の画像を保存する
        ImageComparisonState imageComparisonState = imageComparisonResult.getImageComparisonState();
        BufferedImage resultImage = imageComparisonResult.getResult();
        ImageComparisonUtil.saveImage(resultDestination, resultImage);
        return false;
    }
}

比較対象の検索結果画面

比較するGoogleウルシステムズと検索し検索結果を表示した画面のキャプチャ画像です。

search_uls.png

比較結果の画像

元画像と比較を行い異なった部分が赤い長方形でマークされた画像です。 search_uls_compareimage.png

まとめ

今回はImageComparisonを使用し画像比較による自動テストを行ってみました。 画面の一つ一つの項目に対して検証用コードを書くのはとても大変ですが、画像比較を活用する事で同一かどうか違う場合は何処が違っているのかを一気に見つける事が出来ます。 注意点としては、事前に正となる画面の画像が必要、あくまで静的な画面画像による比較検証ですので画面の動作検証には適用できません。

このように画像比較を使用しテストコード作成の工数削減を図ってみてはどうでしょうか。

「みんなのJava」が来た! #minjava

2020/03/06に発売の「みんなのJava」という書籍を @kencharos さんより献本を頂きました。ありがとうございます! gihyo.jp と言う事で、早速読んでみました。

本書の内容に関して

内容は、Java 9 から Java 14の変化、JDKディストリビューションJakarta EE、MicroProfile、GreeVM、次世代フレームワークと幅広く網羅されています。 各章をそれぞれ詳しく書くととても一冊の本ではすまない範囲の為、章によってはあまり深く記載されていませんが、今のJavaを網羅的に知るにはとても良い本ではないでしょうか。

著者の方々

そして、なんと言っても各章を書かれている著者の方々です。各章とも日本のJavaコミュニティで、その章を代表する方々が書かれているため正しい情報へのアクセスが出来ます。本書に書かれているように大変革期には、このように正しい情報を知る事はとても大切だと思います。

個人的に興味があった箇所

第一章のJava SEに関する内容で、LTS版のJava 11以降あまり追えていなかったのでとてもワクワクする内容でした。次のLTS版であるJava 17が出るまで待つと、とても追いつけなくなるなと思いました。また、第二章のJDKディストリビューションの章では、リリースサイクル変更に伴いどの様な変更が起きたか現在どの様なJDKが存在するのかなど詳しく書かれています。なかなかここまで詳しく書かれているものは無く改めて勉強になりました。リリースサイクル変更の話をコミュニティなどでの勉強会に参加し聞いた事が無い方などには是非読んできただきたい内容です。

そう言う私もまだ修行が足りないせいか、話の中でOpenJDKとOracle OpenJDKが混在してくると時折「んんん?」ってなります(汗)

さいごに

本書を送って頂いた@kencharos さん、著者の方々大変ありがとう御座います。

Javaエンジニアの方々は是非手に取ってお読み下さい!

MicroProfile OpenTracing with Helidon

はじめに

この記事は、UL Systems Advent Calendar 2019 - 18日目の転記です。

本日は、HelidonTracing MP Guideを参考にEclipse MicroProfile OpenTracingJaegerで分散トレーシングの可視化を試したいと思います。(Tracing MP GuideではZipkinを使用していますが今回はロゴの可愛さからJaegerを使用してみたいと思います。)

最近では、マイクロサービスアーキテクチャでシステム全体を構成する事も多くなってきました。その際、従来のモノリシックなシステムで使用していたメソッドトレースなどでは複数のサービスを跨がる処理に障害や遅延が起きた場合、どこで発生しているのか判別が難しい為、全体を通してトレースする分散トレーシングと呼ばれる仕組みが必要になります。

jaeger.png

Helidon とは

2018年9月にOracle社より発表された、Java向けのマイクロサービス作成に活用できる軽量マイクロサービス開発のためのオープンソースベースのフレームワークです。

Helidonの特徴

  • 2つのプログラミングモデル
    • Helidon SE
      • 関数型スタイル開発、シンプル
      • インジェクション非対応
    • Helidon MP
  • Docker, k8s との連携
  • Oracleによる有償サポートサービス有り
  • GraaalVMへの対応

今回は MaicroProfile をサポートしているHelidon MPをしようします。

Eclipse MicroProfile とは

Eclipse MicroProfileは、マイクロサービス向け Enterprise Java API の仕様です。現在では、JAX-RSJSON-P、CDIなどのAPIが含まれており、構成、メトリック、フォールトトレランスなどのAPIも存在します。 今回使用する Helidon MP は Eclipse MicroProfile 3.2 をサポートしています。

OpenTracing とは

OpenTracingは、API仕様とそれを実装したフレームワークとライブラリ、およびプロジェクトのドキュメントで構成されています。 OpenTracingを使用すると、開発者は特定の製品やベンダーにロックされていないAPIを使用してアプリケーションコードにトレーシング機能を追加できます。

Jaeger とは

DapperとOpenZipkinにインスパイヤーされUber TechnologiesによってリリースされたオープンソースのOpenTracing互換の分散トレースシステムです。 今回は下記の図の OpenTracing API 部分が MicroProfile OpenTracing になりjaeger-client の部分が Helidon から提供されているJaeger用のClientになります。

20181125234256.png

Jaeger のセットアップ

それではまず、Client以外の部分に関してセットアップを行いたいと思います。セットアップに関してはAll-in-oneのDockerイメージが公開されているのでそれを使用します。 (必要最低限のポートのみを指定して起動しています)

$ docker run -d --name jaeger \
   -p 5778:5778 \
   -p 16686:16686 \
   -p 14268:14268 \
   jaegertracing/all-in-one:1.15

各ポートの説明

Helidon の jaeger-client はデフォルトではHTTPの5778を使用しているようです。 (これは設定により変更可能です)

キャプチャ004.JPG

Main サービス の作成

次にメインとなるサービスプロジェクトを Helidon MP Maven archetype の quickstart を使用して作成します。

$ mvn archetype:generate -DinteractiveMode=false \
    -DarchetypeGroupId=io.helidon.archetypes \
    -DarchetypeArtifactId=helidon-quickstart-mp \
    -DarchetypeVersion=1.4.0 \
    -DgroupId=io.helidon.main_service \
    -DartifactId=main_service \
    -Dpackage=io.helidon.main_service

Jaeger Clientを追加

pom.xml に以下の依存関係を追加し Jaeger Client を追加します。

<dependency>
    <groupId>io.helidon.tracing</groupId>
    <artifactId>helidon-tracing-jaeger</artifactId>
</dependency>

トレーシング サービス名の追加

Helidon から Jaeger に送信されるトレーシングデータへ紐付ける為のサービス名をMETA-INF/microprofile-config.propertiesへ指定します。

tracing.service=helidon-main-service

Main サービス を動かして見る

では、ここまで作成した結果を見てみたいと思います。quickstart で作成したプロジェクトは、io.helidon.main_service.MainクラスのMainメソッドを実行する事でサーバが起動するように作成されています。

サーバ起動後にhttp://localhost:8080/greetへアクセスすると quickstart で作成されたサービスが実行され{"message":"Hello World!"}が表示されるはずです。

$ curl http://localhost:8080/greet
{"message":"Hello World!"}

数回アクセスした後に jaeger UI(http://localhost:16686/search) からトレーシングデータを見ると以下のようなトレーシングデータが可視化されるはずです。 キャプチャ003.JPG

さらに各トレースをクリックすると、スパンがリストされているトレース詳細ページが表示されます。 ルートスパンと、トレース内のすべてのスパン間の関係、およびタイミング情報を明確に確認できます。 キャプチャ005.JPG

各スパン行をクリックすると、スパンの詳細も確認できます。 キャプチャ006.JPG

クラスやメソッドレベルでのトレーシング

さらに、MicroProfile OpenTracingで提供されている@Tracedアノテーションを付与することでクラスやメソッドレベルでのトレーシングも可能です。

@Traced
@ApplicationScoped
public class GreetingProvider {
...
}

キャプチャ007.JPG

サービス間を跨いだトレーシング

次にサービス間を跨いだトレーシングを行って見たいと思います。

Second サービスの作成

Main サービスと同様に Helidon MP Maven archetype の quickstart を使用して作成し、Jaeger Client を追加します。

$ mvn archetype:generate -DinteractiveMode=false \
    -DarchetypeGroupId=io.helidon.archetypes \
    -DarchetypeArtifactId=helidon-quickstart-mp \
    -DarchetypeVersion=1.4.0 \
    -DgroupId=io.helidon.second_service \
    -DartifactId=second_service \
    -Dpackage=io.helidon.second_service
<dependency>
    <groupId>io.helidon.tracing</groupId>
    <artifactId>helidon-tracing-jaeger</artifactId>
</dependency>

設定変更

  • greetingで返される文字をSecond サービスからの返却だと識別しやすいようにHello From Second Serviceと変更します。
  • 起動ポートを8081へ変更します。
# Application properties. This is the default greeting
app.greeting=Hello From Second Service

# Microprofile server properties
server.port=8081

動作確認

Second サービスを実行し動作確認をすると以下のような結果になります。

$ curl http://localhost:8081/greet
{"message":"Hello From Second Service World!"}

Main サービスからSecond サービスの呼出

最初に作成したMain サービスからSecond サービスを呼び出すように変更します。

まずは、Second サービスを呼び出すためのRest ClientをMain サービスに作成する必要があります。 Tracing MP Guide とは違いここではMicroProfile Rest Clientを使用して作成してみたいと思います。

package io.helidon.main_service;

import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import javax.json.JsonObject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;

@RegisterRestClient(baseUri="http://localhost:8081")
public interface SecondServiceClient {
    @Path("/greet")
    @GET
    JsonObject getDefaultMessage();
}

そして、作成したClientを使用しMain サービスからSecond サービスの呼出を行います。 作成したSecondServiceClientを呼出クラスへインジェクションし元々レスポンスを返していたメソッドへ SecondServiceClient を使用しSecond サービスの呼出を行います。

@Path("/greet")
@RequestScoped
public class GreetResource {
    @Inject
    @RestClient
    SecondServiceClient secondServiceClient;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public JsonObject getDefaultMessage() {
        // return createResponse("World");
        return secondServiceClient.getDefaultMessage();
    }

動作確認

ポート8080のMain サービスを実行しSecond サービスが呼ばれていることを確認します。

$ curl http://localhost:8080/greet
{"message":"Hello From Second Service World!"}

分散トレーシングデータの確認

今回の処理は、コンソールからのリクエストがMain サービスを回しSecond サービスを呼び出すと言う構成になっています。 キャプチャ009.JPG

このリクエストのトレーシングデータをJaeger UIで見てみると全体でそれだけの処理時間がかかり、処理の流れが確認できます。 キャプチャ008.JPG

まとめ

今回はEclipse MicroProfile OpenTracingを使用し分散トレーシングを試してみました。 分散システムを採用した場合に分散トレーシングの可視化が出来ている状態であれば、仮にシステムに障害や遅延が発生しても把握しやすいのではないでしょうか。

また、Eclipse MicroProfile には OpenTracing の他にもEclipse MicroProfile MetricsやEclipse MicroProfile Fault Toleranceなどの仕様が有り Helidon もそれらをサポートしています。現在JavaEEを使用しているアプリケーションからの移行など Spring Framework 以外の選択として検討する事が出来るのではないでしょうか。

Mavenを利用してWebLogic12cへデプロイしてみた

今さらですが、Mavenを利用してWeblogic12cへデプロイする為のMavenの設定方法などのメモです

weblogic-maven-pluginプラグインのインストール

ローカルにWebLogicをインストールしている場合

1. Oracle Maven同期プラグインのインストール

ディレクトリを${ORACLE_HOME}\oracle_common\plugins\maven\com\oracle\maven\oracle-maven-sync\12.2.1へ移動し下記を実行します。 (WebLogic Server 12.2.1 の場合)

$ mvn install:install-file -DpomFile=oracle-maven-sync-12.2.1.pom -Dfile=oracle-maven-sync-12.2.1.jar
2. ローカルにあるモジュールをローカルレポジトリに取り込む
$ mvn com.oracle.maven:oracle-maven-sync:push -DoracleHome=${ORACLE_HOME}

ローカルにWebLogicをインストールしていない場合

ローカルにWeblogicをインストール指定に場合は、Oracle Mavenリポジトリから必要なモジュールを取得する。

1. Oracle OTN Account の作成

Oracle Mavenリポジトリにアクセスするのに必要ですので事前に作成します。 www.oracle.com

2. 必要な接続情報等の設定を行う

リポジトリプラグインリポジトリの設定をpom.xmlもしくはsetting.xmlへ、認証情報をsetting.xmlへ下記の様に記述します。

pom.xml or setting.xml

  <repositories>
    <repository>
      <id>maven.oracle.com</id>
      <releases>
        <enabled>true</enabled>
      </releases>
      <snapshots>
        <enabled>false</enabled>
      </snapshots>
      <url>https://maven.oracle.com</url>
      <layout>default</layout>
    </repository>
  </repositories>
  <pluginRepositories>
    <pluginRepository>
      <id>maven.oracle.com</id>
      <url>https://maven.oracle.com</url>
    </pluginRepository>
  </pluginRepositories>

setting.xml

  <servers>
    <server>
      <id>maven.oracle.com</id>
        <username>xxxxxxxxxxxxxxxxxxxxxxxx</username>
        <password>xxxxxxxxxxxxxxxxxxxxxxxx</password>
      <configuration>
        <basicAuthScope>
          <host>ANY</host>
          <port>ANY</port>
          <realm>OAM 11g</realm>
        </basicAuthScope>
        <httpConfiguration>
          <all>
            <params>
              <property>
                <name>http.protocol.allow-circular-redirects</name>
                <value>%b,true</value>
              </property>
            </params>
          </all>
        </httpConfiguration>
      </configuration>
    </server>
  </servers>

usernameやpasswordはmavenの機能でmvn --encrypt-master-passwordmvn --encrypt-passwordを利用して暗号化できます。

WebLogic プラグインの追加

下記の様にpom.xmlへ記述しWebLogic Pluginの追加をします。

<build>
    <plugins>
      <plugin>
        <!-- This is the configuration for the weblogic-maven-plugin -->
        <groupId>com.oracle.weblogic</groupId>
        <artifactId>weblogic-maven-plugin</artifactId>
        <version>12.2.1-0-0</version>
        <configuration>
         <middlewareHome>/fmwhome/wls12210</middlewareHome>
        </configuration>
        <executions>
          <!-- Execute the appc goal during the package phase -->
          <execution>
            <id>wls-appc</id>
            <phase>package</phase>
            <goals>
              <goal>appc</goal>
            </goals>
            <configuration>
             <source>${project.build.directory}/${project.name}.${project.packaging}</source>
            </configuration>
          </execution>
          <!-- Deploy the application to the WebLogic Server in the pre-integration-test phase -->
          <execution>
            <id>wls-deploy</id>
            <phase>pre-integration-test</phase>
            <goals>
              <goal>deploy</goal>
            </goals>
            <configuration>
              <!--The admin URL where the app is deployed. Here use the plugin's default value t3://localhost:7001-->
              <adminurl>t3://127.0.0.1:7001</adminurl>
              <user>weblogic</user>
              <password>password</password>
              <!--The location of the file or directory to be deployed-->
              <source>${project.build.directory}/${project.build.finalName}.${project.packaging}</source>
              <!--The target servers where the application is deployed. Here use the plugin's default value AdminServer-->
              <targets>AdminServer</targets>
              <verbose>true</verbose>
              <name>${project.build.finalName}</name>
            </configuration>
          </execution>
          <!-- Stop the application in the pre-integration-test phase -->
          <execution>
            <id>wls-stop-app</id>
            <phase>pre-integration-test</phase>
            <goals>
              <goal>stop-app</goal>
            </goals>
            <configuration>
              <adminurl>t3://127.0.0.1:7001</adminurl>
              <user>weblogic</user>
              <password>password</password>
              <name>${project.build.finalName}</name>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build> 

WebLogic12cへデプロイ

下記のコマンドでデプロイします。

mvn com.oracle.weblogic:weblogic-maven-plugin:deploy

その他にもコマンドがあります。 docs.oracle.com

Oracle Mavenリポジトリを使用する場合は、初回実行時はWebLogicのモジュール情報を取得するので時間がかかります。 また2回目移行もモジュール情報のチェック?を行うので時間がかかります。 その場合は、-oを指定してオフラインモードで実行します。

参考サイト

EnvoyとJaegerを使用した分散トレーシングを試してみた

Envoyとは

ライドシェアサービスのLyftが開発し現在はCNCFでホストされている大規模サービス指向アーキテクチャ用L4/L7プロキシです。 また、Kubernetesを使用した場合にサービスメッシュをマネージメントために使用されるIstioのコアコンポーネントの一つです。

www.envoyproxy.io

ただ、Envoy自体は単なるプロキシサーバなのでKubernetesを使わなくても使用する事は可能です。 今回はEnvoyを使用して分散トレーシングデータの収集を行いJaegerを使用して可視化して見たいと思います。

サンドボックス[Jaeger Tracing]

Envoyの公式サイトにサンドボックスが用意されているのでその中の'Jaeger Tracing'を試してみました。

Jaeger Tracingを立ち上げる

提供されているdocker-composeを使用し立ち上げると下記の様に4つのコンテナが立ち上がります。

$ docker-compose up -d
Creating network "jaeger-tracing_envoymesh" with the default driver
Creating jaeger-tracing_service1_1    ... done
Creating jaeger-tracing_front-envoy_1 ... done
Creating jaeger-tracing_service2_1    ... done
Creating jaeger-tracing_jaeger_1      ... done

$ docker-compose ps
            Name                          Command               State                                                       Ports                                                     
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
jaeger-tracing_front-envoy_1   /docker-entrypoint.sh /bin ...   Up      10000/tcp, 0.0.0.0:8000->80/tcp, 0.0.0.0:8001->8001/tcp                                                       
jaeger-tracing_jaeger_1        /go/bin/all-in-one-linux - ...   Up      14250/tcp, 14268/tcp, 0.0.0.0:16686->16686/tcp, 5775/udp, 5778/tcp, 6831/udp, 6832/udp, 0.0.0.0:9411->9411/tcp
jaeger-tracing_service1_1      /bin/sh -c /usr/local/bin/ ...   Up      10000/tcp, 80/tcp                                                                                             
jaeger-tracing_service2_1      /bin/sh -c /usr/local/bin/ ...   Up      10000/tcp, 80/tcp                                                                                  

されにそれぞれどのようプロセルが立ち上がっているか見てみます。

  • Front Envoyコンテナ:フロントに立ってルーティングを行うEnvoyのコンテナです
$ docker exec -it jaeger-tracing_front-envoy_1 bash
root@63dc868edcda:/# ps auxwwf
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root        47  1.5  0.1  18232  3316 pts/0    Ss   16:58   0:00 bash
root        57  0.0  0.1  34420  2904 pts/0    R+   16:58   0:00  \_ ps auxwwf
root         1  0.0  0.0   4500   696 ?        Ss   16:22   0:00 /bin/sh -c /usr/local/bin/envoy -c /etc/front-envoy.yaml --service-cluster front-proxy
root         7  0.3  1.0 116736 22152 ?        Sl   16:22   0:07 /usr/local/bin/envoy -c /etc/front-envoy.yaml --service-cluster front-proxy
  • Serviceコンテナ:提供するRest APIサービスとEnvoyが同梱されたコンテナです
$ docker exec -it jaeger-tracing_service1_1 bash
bash-4.4# ps auxwwf
PID   USER     TIME  COMMAND
    1 root      0:00 {start_service.s} /bin/sh /usr/local/bin/start_service.sh
    7 root      0:00 python3 /code/service.py
    8 root      0:07 envoy -c /etc/service-envoy.yaml --service-cluster service1
   19 root      0:29 /usr/bin/python3 /code/service.py
   23 root      0:00 bash
   30 root      0:00 ps auxwwf
  • Jaegerコンテナ:Jaegerに必要なコンポートが一式入ったAll in Oneのコンテナです。今回はUI(port:16686)とcollector(port:9411)を使用しています

Front EnvoyコンテナとServiceコンテナは下記の図の様な構成になっており、各ServiceのEnvoyからJaegerコンテナのcollectorへトレーシングデータの送信がされます。

f:id:yasu7ri:20181228054957p:plain

  • front-envoyの設定ファイル(front-envoy-jaeger.yaml)の概要
    • 0.0.0.0:80 を listenして全てのリクエストをclusterで定義されているservice1へルーティングする
static_resources:
  listeners:
  - address:
      # 0.0.0.0:80でlistenする
      socket_address:
        address: 0.0.0.0
        port_value: 80
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          tracing:
            operation_name: egress
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            # 全てのリクエストをservice1と宣言しているclusterへルーティングする
            - name: backend
              domains:
              - "*"
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: service1
                decorator:
                  operation: checkAvailability
          http_filters:
          - name: envoy.router
            config: {}
  clusters:
  # service1のcluster、service1のポート80へ
  - name: service1
    connect_timeout: 0.250s
    type: strict_dns
    lb_policy: round_robin
    http2_protocol_options: {}
    hosts:
    - socket_address:
        address: service1
        port_value: 80
  - name: jaeger
    connect_timeout: 1s
    type: strict_dns
    lb_policy: round_robin
    hosts:
    - socket_address:
        address: jaeger
        port_value: 9411
tracing:
  http:
    name: envoy.zipkin
    config:
      collector_cluster: jaeger
      collector_endpoint: "/api/v1/spans"
      shared_span_context: false
admin:
  access_log_path: "/dev/null"
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 8001
  • service1-envoyの設定ファイル(service1-envoy-jaeger.yaml)の概要
    • 0.0.0.0:80 を listenして全てのリクエストをclusterで定義されているlocal_serviceへルーティングする
    • 0.0.0.0:9000 を listenしてprefixが'/trace/2'のリクエストの場合はclusterで定義されているservice2へルーティングする
static_resources:
  listeners:
  - address:
      socket_address:
        address: 0.0.0.0
        port_value: 80
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          tracing:
            operation_name: ingress
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: service1_route
            virtual_hosts:
            - name: service1
              domains:
              - "*"
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: local_service
                decorator:
                  operation: checkAvailability
          http_filters:
          - name: envoy.router
            config: {}
  - address:
      socket_address:
        address: 0.0.0.0
        port_value: 9000
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          tracing:
            operation_name: egress
          codec_type: auto
          stat_prefix: egress_http
          route_config:
            name: service2_route
            virtual_hosts:
            - name: service2
              domains:
              - "*"
              routes:
              - match:
                  prefix: "/trace/2"
                route:
                  cluster: service2
                decorator:
                  operation: checkStock
          http_filters:
          - name: envoy.router
            config: {}
  clusters:
  - name: local_service
    connect_timeout: 0.250s
    type: strict_dns
    lb_policy: round_robin
    hosts:
    - socket_address:
        address: 127.0.0.1
        port_value: 8080
  - name: service2
    connect_timeout: 0.250s
    type: strict_dns
    lb_policy: round_robin
    http2_protocol_options: {}
    hosts:
    - socket_address:
        address: service2
        port_value: 80
  - name: jaeger
    connect_timeout: 1s
    type: strict_dns
    lb_policy: round_robin
    hosts:
    - socket_address:
        address: jaeger
        port_value: 9411
tracing:
  http:
    name: envoy.zipkin
    config:
      collector_cluster: jaeger
      collector_endpoint: "/api/v1/spans"
      shared_span_context: false
admin:
  access_log_path: "/dev/null"
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 8001

Service1へのリクエストを行う

$ curl -v localhost:8000/trace/1
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8000 (#0)
> GET /trace/1 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< content-type: text/html; charset=utf-8
< content-length: 89
< server: envoy
< date: Thu, 27 Dec 2018 17:50:09 GMT
< x-envoy-upstream-service-time: 62
< 
Hello from behind Envoy (service 1)! hostname: 74aee62abcce resolvedhostname: 172.22.0.2
* Connection #0 to host localhost left intact
リクエストのシーケンス

f:id:yasu7ri:20181228043607p:plain

Jaeger UI でトレーシング結果を見る

f:id:yasu7ri:20181228050457p:plain

f:id:yasu7ri:20181228050512p:plain

[おまけ]ServiceをSpring Bootで作成してみる

トレースしたいアプリケーションを作成する際の注意点として、Envoyが生成したトレースヘッダーを独自に伝播させる実装を行う必要があります。 サンドボックスでは、service1として機能する単純なアプリケーション(service.py)が、service2への呼び出しを行う際にトレースヘッダーを伝播を実装しています。

@RestController
@RequestMapping("/trace/1")
public class MainController {
    private static final String[] TRACING_HEADERS = {
            "X-Ot-Span-Context", "X-Request-Id",
            // Zipkin headers
            "X-B3-TraceId", "X-B3-SpanId", "X-B3-ParentSpanId", "X-B3-Sampled", "X-B3-Flags",
            // Jaeger header (for native client)
            "uber-trace-id"};

    @Autowired
    private RestTemplate restTemplate;

    @RequestMapping(method = RequestMethod.GET)
    public String main(HttpServletRequest request) {
        HttpEntity entity = new HttpEntity(tracingHeaders(request));

        ResponseEntity<String> response = restTemplate.exchange(
                "http://localhost:9000/trace/2",
                HttpMethod.GET,
                entity,
                String.class);
        return response.getBody();
    }

    private HttpHeaders tracingHeaders(HttpServletRequest request) {
        final HttpHeaders headers = new HttpHeaders();
        Arrays.asList(TRACING_HEADERS).stream().forEach(s -> {
            String h = request.getHeader(s);
            if (StringUtils.isEmpty(h)) return;
            headers.set(s, h);
        });
        return headers;
    }
}

参考サイト

Spring BootでJaegerを使用してトレーシングしてみた

Spring BootでJaegerを使用してトレーシングしてみた

今回のサービスの構成

今回の構成はmainサービス、formatterサービス、publisherサービスの3つのサービスを作成します。

  • formatterサービスは、クエリーパラメータで渡された文字列を"Hello, %s!"の形式に変換し返します。
  • publisherサービスは、クエリーパラメータで渡された文字列をSystem.out.println(String)で標準出力します。
  • mainサービスは、クエリーパラメータで渡された文字列をformatterサービスへ渡し変換します。そして、その変換後の文字列をpublisherサービスへ渡します。
formatterサービス
@RestController
@RequestMapping("/formatter")
public class FormatController {
    @RequestMapping(method = RequestMethod.GET)
    public String format(@RequestParam String str) {
        return String.format("Hello, %s!", str);
    }
}
publisherサービス
@RestController
@RequestMapping("/publisher")
public class PublishController {
    @RequestMapping(method = RequestMethod.GET)
    public String publish(String str) {
        System.out.println(str);
        return "published:" + str;
    }
}
mainサービス
@RestController
@RequestMapping("/main")
public class MainController {
    @Autowired
    private RestTemplate restTemplate;

    @RequestMapping(method = RequestMethod.GET)
    public String main(@RequestParam String str) {
        ResponseEntity<String> FormatResponse = restTemplate.getForEntity(
                "http://localhost:8081/formatter?str={str}",
                String.class,
                str);
        String formatString = FormatResponse.getBody();

        ResponseEntity<String> publishResponse = restTemplate.getForEntity(
                "http://localhost:8082/publisher?str={str}",
                String.class,
                formatString);
        return publishResponse.getBody();
    }
}

Spring BootでJaegerを使用する方法

前回まではOpenTracingのチュートリアルに沿ってSpanコンテキストを伝播させる仕組みなどを独自に実装していきましたが、 今回のSpring Bootで作成されてたREST APIの場合は、その様にすればトレーニング出来るのかを見てみたいと思います。

まず、下記のページにOpenTracingのトレーサーの実装とその他のアドインが掲載されいますので目的に沿ったプロジェクトを探します。 github.com

今回はSpring BootでREST APIを作成するので、この中からTracerにjava-spring-jaeger - Spring starter for JaegerをOpenTracingの実装にjava-spring-web - Springを使用します。

ライブラリの依存関係(pom.xml)
    <!-- https://mvnrepository.com/artifact/io.opentracing.contrib/opentracing-spring-web-starter -->
    <dependency>
      <groupId>io.opentracing.contrib</groupId>
      <artifactId>opentracing-spring-web-starter</artifactId>
      <version>1.0.1</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/io.opentracing.contrib/opentracing-spring-jaeger-web-starter -->
    <dependency>
      <groupId>io.opentracing.contrib</groupId>
      <artifactId>opentracing-spring-jaeger-web-starter</artifactId>
      <version>1.0.1</version>
    </dependency>

行う事はこれだけです!

Spring BootでJaegerを使用したトレーシング結果

mainサービスへリクエストした際のトレーシング結果です。

f:id:yasu7ri:20181214163751j:plain

こちらがトレースした結果を展開したもの

f:id:yasu7ri:20181214163316j:plain

Spring BootでJaegerを使用してトレース情報を可視化は、ライブラリの追加だけで既存のソースコードに手を入れなくてもトレース情報を取得出来るのでとてもお手軽に出来ました。

OpenTracingチュートリアルをやってみた(その2)

OpenTracingチュートリアルをやったメモ

前回の「OpenTracingチュートリアルをやってみた(その1)」の続きです。

リモートプロセス呼出をトレーシングしてみる

前回のつづきLesson 3 - Tracing RPC Requestsを。

前回作成したformatStringメソッドとprintHelloメソッドの内容をFormatterサービスとPublisherサービスの2つのサービスに独立させトレーシングしてみます。

  • 前回レッスンのHello.javaをベースに処理をHTTP呼出のサービスへ独立させ呼び出します。
    • FormatterサービスはGET 'http://localhost:8081/format?helloTo=Bryan'でリクエストを受け"Hello, Bryan!"を返却するAPIに変更します。
    • PublisherサービスはGET 'http://localhost:8082/publish?helloStr=Hello%2c%20Bryan'でリクエストを受け"Hello, Bryan"と標準出力します。
  • Hello.javaではHTTP Clientを使用し2つのサービスを呼び出します。
サービス間のコンテキスト伝播について

まず、リモートプロセス呼出のトレースを続けるためにはSpanコンテキストを伝播する必要があります。 OpenTracing APIはそれを実現する為にTracerインターフェースにinject(spanContext, format, carrier)extract(format, carrier)の2つのメソッドを提供します。 引数のformatのパラメータはOpenTracing APIが定義する3つの標準エンコーディングのうちの1つを指定します。

  • TEXT_MAP:Spanコンテキストは文字列のKey:Valueのコレクションにされます。
  • HTTP_HEADERS:TEXT_MAPに似ていますが、安全にHTTPヘッダーを使用します。
  • BINARY:Spanコンテキストはバイト配列にされます。

Inject and Extract

Client側の実装

tracer.injectを利用してspanContextをHTTPリクエストに乗せて伝播させます。下記の例ではHTTP_HEADERSを使用します。

    private String formatString(String helloTo) {
        try (Scope scope = tracer.buildSpan("formatString").startActive(true)) {
            String helloStr = getHttp(8081, "format", "helloTo", helloTo);
            scope.span().log(ImmutableMap.of("event", "string-format", "value", helloStr));
            return helloStr;
        }
    }

    private void printHello(String helloStr) {
        try (Scope scope = tracer.buildSpan("printHello").startActive(true)) {
            getHttp(8082, "publish", "helloStr", helloStr);
            scope.span().log(ImmutableMap.of("event", "println"));
        }
    }

    private String getHttp(int port, String path, String param, String value) {
        try {
            HttpUrl url = new HttpUrl.Builder().scheme("http").host("localhost").port(port).addPathSegment(path)
                    .addQueryParameter(param, value).build();
            Request.Builder requestBuilder = new Request.Builder().url(url);

            Tags.SPAN_KIND.set(tracer.activeSpan(), Tags.SPAN_KIND_CLIENT);
            Tags.HTTP_METHOD.set(tracer.activeSpan(), "GET");
            Tags.HTTP_URL.set(tracer.activeSpan(), url.toString());
            tracer.inject(tracer.activeSpan().context(), Format.Builtin.HTTP_HEADERS, new HttpHeadersCarrier(requestBuilder));

            Request request = requestBuilder.build();
            Response response = client.newCall(request).execute();
            if (response.code() != 200) {
                throw new RuntimeException("Bad HTTP result: " + response);
            }
            return response.body().string();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static class HttpHeadersCarrier implements TextMap {
        private final Request.Builder builder;

        HttpHeadersCarrier(Request.Builder builder) {
            this.builder = builder;
        }
        @Override
        public Iterator<Map.Entry<String, String>> iterator() {
            throw new UnsupportedOperationException("carrier is write-only");
        }
        @Override
        public void put(String key, String value) {
            builder.addHeader(key, value);
        }
    }

パケットを確認するとuber-trace-idというheaderで渡されています。

f:id:yasu7ri:20181202200726p:plain

ちなみにTEXT_MAPで行った場合は下記の様になっています。

f:id:yasu7ri:20181202222146p:plain

TraceId、SpanId、ParentIdがコロン区切りで繋げた文字列をHTTP_HEADERSを指定した場合はURLエンコーディングした物になり、TEXT_MAPの場合はそのままheaderに設定されるようです。

io.jaegertracing.internal.propagation.TextMapCodec#contextAsString(JaegerSpanContext context)

  public static String contextAsString(JaegerSpanContext context) {
    int intFlag = context.getFlags() & 0xFF;
    return new StringBuilder()
        .append(context.getTraceId()).append(":")
        .append(Long.toHexString(context.getSpanId())).append(":")
        .append(Long.toHexString(context.getParentId())).append(":")
        .append(Integer.toHexString(intFlag))
        .toString();
  }

  private String encodedValue(String value) {
    if (!urlEncoding) {
      return value;
    }
    try {
      return URLEncoder.encode(value, "UTF-8");
    } catch (UnsupportedEncodingException e) {
      // not much we can do, try raw value
      return value;
    }
  }

jaeger-client-java/TextMapCodec.java at 88a849722d7056557a2643330737967d77b87f88 · jaegertracing/jaeger-client-java · GitHub

Service側の実装

チュートリアルではサービスはDropwizardを使用してサービスを実装しています。

まず、各サービスではHello.javaと同様にTracerインスタンを生成します。

各サービスではClient側と同じようにbuildSpan()を使用してScopeオブジェクトを生成しますが、 まず標準アダプタのTextMapExtractAdapterを使用してリクエストのheaderから伝播されたspanContext情報をHashMap <String、String>に変換し extract(format, carrier)を使用してspanContextを取得します。

header情報からspanContextが生成された場合は、その子SpanとしてScopeオブジェクトを生成します。

public class Formatter extends Application<Configuration> {
    private final Tracer tracer;

    private Formatter(Tracer tracer) {
        this.tracer = tracer;
    }

    public static void main(String[] args) throws Exception {
        System.setProperty("dw.server.applicationConnectors[0].port", "8081");
        System.setProperty("dw.server.adminConnectors[0].port", "9081");
        io.jaegertracing.Configuration.SamplerConfiguration samplerConfiguration = io.jaegertracing.Configuration.SamplerConfiguration.fromEnv().withType("const").withParam(1);
        io.jaegertracing.Configuration.ReporterConfiguration reporterConfiguration = io.jaegertracing.Configuration.ReporterConfiguration.fromEnv().withLogSpans(true);
        io.jaegertracing.Configuration configuration = new io.jaegertracing.Configuration("formatter").withSampler(samplerConfiguration).withReporter(reporterConfiguration);
        Tracer tracer =  configuration.getTracer();
        new Formatter(tracer).run("server");
    }

    @Override
    public void run(Configuration configuration, Environment environment) throws Exception {
        environment.jersey().register(new FormatterResource());
    }

    @Path("/format")
    @Produces(MediaType.TEXT_PLAIN)
    public class FormatterResource {
        @GET
        public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) {
            try (Scope scope = startServerSpan(tracer, httpHeaders, "format")) {
                return String.format("Hello, %s!", helloTo);
            }
        }
    }

    protected static Scope startServerSpan(Tracer tracer, HttpHeaders httpHeaders, String operationName) {
        MultivaluedMap<String, String> rawHeaders = httpHeaders.getRequestHeaders();
        final HashMap<String, String> headers = new HashMap<String, String>();
        for (String key : rawHeaders.keySet()) {
            headers.put(key, rawHeaders.get(key).get(0));
        }

        Tracer.SpanBuilder spanBuilder;
        try {
            SpanContext parentSpan = tracer.extract(Format.Builtin.HTTP_HEADERS, new TextMapExtractAdapter(headers));
            if (parentSpan == null) {
                spanBuilder = tracer.buildSpan(operationName);
            } else {
                spanBuilder = tracer.buildSpan(operationName).asChildOf(parentSpan);
            }
        } catch (IllegalArgumentException e) {
            spanBuilder = tracer.buildSpan(operationName);
        }
        return spanBuilder.withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_SERVER).startActive(true);
    }
}

Publisherサービスの方も同様に実装します。違いはサービスのポートと実際の処理部分だけです。

トレース結果

Client側のmainメソッドを実行じ2つのサービスを呼出たトレース結果です。

f:id:yasu7ri:20181202231300p:plain

トレーシングコンテキスト以外の伝播

最後にLesson 4 - Baggage

OpenTracingを使用することトレーシングコンテキスト以外のものも伝播出来る様にすることが出来ます。 このトレーシングコンテキスト以外で伝播させたい情報を手荷物と同じように全てのリモートプロセスで持ち回れる事からBaggageと呼びます。

BaggageはScope.span().setBaggageItem(String key, Syring vlue)で設定します。取得はScope.span().getBaggageItem(String key)で行います。

Client側の実装
    private void sayHello(String helloTo) {
        try (Scope scope = tracer.buildSpan("say-hello").startActive(true)) {
            scope.span().setTag("hello-to", helloTo);
            scope.span().setBaggageItem("item1","this is item1");
            String helloStr = formatString(helloTo);
            printHello(helloStr);
        }
    }

設定したBaggageはhaederに追加されています。

f:id:yasu7ri:20181203002535p:plain

Service側の実装
  • Formatterサービス
    @Path("/format")
    @Produces(MediaType.TEXT_PLAIN)
    public class FormatterResource {
        @GET
        public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) {
            try (Scope scope = startServerSpan(tracer, httpHeaders, "format")) {
                String item1 = scope.span().getBaggageItem("item1");
                scope.span().setTag("item1", item1);
                return String.format("Hello, %s!", helloTo);
            }
        }
    }
  • Publisherサービス
    @Path("/publish")
    @Produces(MediaType.TEXT_PLAIN)
    public class PublisherResource {
        @GET
        public String publish(@QueryParam("helloStr") String helloStr, @Context HttpHeaders httpHeaders) {
            try (Scope scope = startServerSpan(tracer, httpHeaders, "publish")) {
                String item1 = scope.span().getBaggageItem("item1");
                scope.span().setTag("item1", item1);
                System.out.println(helloStr);
                return "published";
            }
        }
    }

設定したBaggageはサービスを跨いでも伝播されています。

f:id:yasu7ri:20181203002305p:plain