AWS DevOps - AWS X-Rayによる分散トレーシング その2 (Java編)

#はじめに
前回の記事では、AWS X-Ray の概要を紹介しました。今回の記事では、JavaのフレームワークであるQuarkusとAWS X-Ray SDK for Javaを用いて実際にアプリケーションを作成し、分散トレーシングを実現する手順を紹介します。

# 記事の構成
本連載は以下の3部で構成されています。

1. AWS X-Rayによる分散トレーシング その1 (概要編)
1. **AWS X-Rayによる分散トレーシング その2 (Java編) ← 今回**
1. AWS X-Rayによる分散トレーシング その3 (Node.js編)

# 環境
## システム構成
今回作成したテスト用アプリケーションのシステム構成 (最終形) を以下に示します。
![2-1_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/3010fc89-697b-9e1c-1aae-e85b8906544f.png)
図中に示した通り、コンテナの実行環境にECS、テスト用のクライアントにCloud9を使用し、サービス間の呼び出しはECSサービスディスカバリ (Cloud Map、Route 53との連携) を使用しました。また、コンテナレジストリとしてECRを使用し、他の環境で開発したコンテナを持ち込んで使用しています。(そのため、ECRにアクセスするためのVPCエンドポイントや、プライベートサブネットのCloud9に接続するためのVPCエンドポイントなど、X-Ray以外のVPCエンドポイントも作成しています。)

## バージョン情報
使用したソフトウェアのバージョンは以下になります。

* Quarkus 2.4.1.Final
* AWS X-Ray SDK for Java 2.10.0
* X-Ray Daemon 3.3.3
* Fargateプラットフォーム 1.4.0

# やってみた
ここからは、実際にアプリケーションを構築する手順と分散トレーシングの結果を確認する手順を記載します。なお、アプリケーションの作成は、実行環境 (前述の図) とは別で構築したインターネットに接続できる環境のCloud9で実施しました。

## 基本となるアプリケーションの作成
まずはQuarkusとAWS X-Ray SDK for Javaを使用して、トレース情報を取得するREST API (Service-A) を作成します。

### Quarkusプロジェクトの作成
まず、ベースとなるQuarkusプロジェクトを作成します。今回はREST APIを作成するため、[Quarkus公式ガイドの「Writing JSON REST Services」](https://quarkus.io/guides/rest-json "Quarkus - Writing JSON REST Services")に従ってプロジェクトを作成します。以下のコマンドを実行することで、「service-a」フォルダが作成され、その配下にプロジェクトが作成されます。

```bash
$ mvn io.quarkus.platform:quarkus-maven-plugin:2.4.1.Final:create \
    -DprojectGroupId=sample.xray \
    -DprojectArtifactId=service-a \
    -DclassName="sample.xray.application.resource.ServiceAResource" \
    -Dpath="/servicea" \
    -Dextensions="resteasy-jackson"
```

ServiceAResourceクラスを確認すると、”Hello RESTeasy”という文字列を返すAPIを「/service-a」というパスで公開するためのソースコードが確認できます。 (なお、この段階ではこのクラスをそのまま使用します。)

### 依存関係の追加
次に、作成されたpom.xmlを開き、AWS X-Ray SDK for Javaを使用するための依存関係をdependencyManagement配下、および、dependencies配下に追加します。

```xml:pom.xml
<?xml version="1.0"?>
  ...
  <dependencyManagement>
    <dependencies>
      ...
      <dependency>
        <groupId>com.amazonaws</groupId>
        <artifactId>aws-xray-recorder-sdk-bom</artifactId>
        <version>2.10.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    ...
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-xray-recorder-sdk-core</artifactId>
    </dependency>
  </dependencies>
  ...
```

また、web.xmlを使用するために、「quarkus-undertow」への依存性を追加します。

```bash
$ ./mvnw quarkus:add-extension -Dextensions="quarkus-undertow"
```

### Servlet Filterの追加
受信リクエストのトレースには、X-Ray SDKが提供しているServlet Filterを使用します。Quarkusでは「resources/META-INF」配下に、以下のようなweb.xmlを作成します。

```xml:web.xml
<web-app>
  <filter>
    <filter-name>AWSXRayServletFilter</filter-name>
    <filter-class>com.amazonaws.xray.javax.servlet.AWSXRayServletFilter</filter-class>
    <init-param>
      <param-name>fixedName</param-name>
      <param-value>Service-A</param-value>
    </init-param>
  </filter>
  <filter-mapping>
    <filter-name>AWSXRayServletFilter</filter-name>
    <url-pattern>*</url-pattern>
  </filter-mapping>
</web-app>
```

### アプリケーションのビルド
アプリケーションをECSで実行するためにビルド (コンテナ化) します。まず、「container-image-docker」への依存性を追加します。

```bash
$ ./mvnw quarkus:add-extension -Dextensions="container-image-docker"
```

次に、ビルドを実行します。

```bash
$ ./mvnw clean package -Dquarkus.container-image.build=true
```

ビルドを実行すると、コンテナイメージが追加されていることが確認できます。 (なお、X-Ray Daemonを起動していないため、ビルト時のテスト用リクエストに対する処理でエラーが発生しますが、ここでは無視します。)

```bash
$ docker images
REPOSITORY          TAG              IMAGE ID       CREATED          SIZE
xxxxxxxx/service-a  1.0.0-SNAPSHOT   xxxxxxxxxxxx   26 seconds ago   394MB
…
```

最後に、[Amazon ECR ユーザーガイド](https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/docker-push-ecr-image.html "Amazon ECR ユーザーガイド - Dockerイメージをプッシュする")を参考に、作成したコンテナイメージをECRにプッシュします。これで、基本となるアプリケーションの作成は完了です。

## アプリケーションのデプロイと実行
次に、作成したコンテナをECS上にデプロイし、X-Ray Daemon経由でトレース情報をX-Rayに送信します。なお、ECSを実行するVPCやサブネット、VPCエンドポイント、および、クライアントとなるCloud9は作成済とします。

### ECSクラスタの作成
AWSマネジメントコンソールからECSを開き、クラスターの作成からECSクラスターを作成します。この際、クラスターテンプレートは「ネットワーキングのみ」を選択し、クラスター名は任意の名称を入力します。

### ECSタスク用IAMロールの作成
[Amazon ECS 開発者ガイド](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-iam-roles.html#create_task_iam_policy_and_role "Amazon ECS 開発者ガイド - タスク用のIAMロールとポリシーの作成")を参考に、ECSタスクが使用するIAMロールを作成します。今回はX-Ray Daemonが必要とする「AWSXRayDaemonWriteAccess」ポリシーを付与したIAMロールを作成します。

### ECSタスク定義とサービスの作成
[Amazon ECS 開発者ガイド](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/create-task-definition.html "Amazon ECS 開発者ガイド - タスク定義の作成")と[AWS X-Ray デベロッパーガイド](https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/xray-daemon-ecs.html "AWS X-Ray デベロッパーガイド - Amazon ECSでX-Rayデーモンの実行")を参考に、ECSのタスク定義を作成します。AWS X-Ray SDK for Javaのデフォルト設定は、X-Ray Daemonが同一ホスト上の2000番ポートで動作していることを前提としているため、この前提を満たすようにService-AのコンテナとX-Ray Daemonを同一のタスク定義に配置します。

タスク定義を作成したら、[Amazon ECS 開発者ガイド](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/create-service-console-v1.html "Amazon ECS 開発者ガイド - クラシックコンソールを使用したサービスの作成")を参考に、ECSサービスを作成します。なお、Cloud9からDNS名でサービスにアクセスできるようにサービスディスカバリ統合を有効化します。サービスディスカバリ統合を有効化してサービスを作成すると、Route53にホストゾーンが作成され、Aレコードが登録されます。

###  Cloud9からの実行
Cloud9でcurlコマンドを実行し、デプロイしたサービスへリクエストを送信します。

```bash
$ curl http://qiita-ecs-service-a.local:8080/servicea
```

その後、ECSのコンソールからX-Ray Daemonのログを確認すると、トレース情報が送信されたことが確認できます。
![4-2-5_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/539791f3-30af-4ff2-7ad1-f4f8772d7d69.png)

## トレース情報の確認
次に、ECSから送信したトレース情報をX-Rayのコンソールから確認します。前述の通り、X-Rayでは「システム全体のトレース情報」と「特定トレース情報の詳細」の2つを可視化できます。

### システム全体のトレース情報
X-Rayのコンソールから「Service Map」を開き、時間帯を選択すると、その時間帯のトレース情報 (呼出関係、頻度、処理時間、エラー率) が可視化できます。
![4-3-1_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/c33c659b-4d7f-b440-b39c-a030b8004b5d.png)
現時点では、クライアントからService-Aを呼び出しただけなので、クライアントとService-Aの呼出関係が表示されています。

### 特定トレース情報の詳細
次に、トレース情報を1つ選択し、その詳細を可視化します。コンソールから「トレース」を選択すると、トレースの一覧が表示されます。
![4-3-2_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/a6e49ea8-3aef-04c5-fc68-21ffc3f79998.png)
トレースIDのリンクをクリックすると、トレースの詳細が可視化できます。
![4-3-2_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/d5b97fd6-9707-f271-7220-c86c952f62f7.png)
こちらも現時点では、1つのセグメントのみが表示されています。

## 呼出先へのトレースIDへの連携
次に、Service-Aから別のサービス (Service-B、Service-C) を呼び出し、呼出関係が可視化できることを確認します。

### Service-B, Service-Cの実装とデプロイ
『[基本となるアプリケーションの作成](#基本となるアプリケーションの作成)』、『[アプリケーションのデプロイと実行](#アプリケーションのデプロイと実行)』の手順と同様に、Service-BとService-Cを作成し、ECSにデプロイします。なお、ECSのクラスターはService-Aと同じクラスターを使用します。

### Service-Aの改修
Service-Aについて、Service-BとService-Cを呼び出すように改修します。呼出先へトレースIDを連携し、一連のトレースとして扱うためには、AWS X-Ray SDK for Javaが提供するHTTPクライアントを使用して呼出処理を実装します。

まず、pom.xmlを開き、dependencies配下にHTTPクライアントを使用するための依存性を追加します。

```xml:pom.xml
<?xml version="1.0"?>
  ...
  <dependencies>
    ...
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-xray-recorder-sdk-apache-http</artifactId>
    </dependency>
  </dependencies>
  ...
```

次に、Service-BとService-Cを呼び出すように修正します。

```Java:ServiceAResource.java
package sample.xray.application.resource;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.io.IOException;

import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.amazonaws.xray.proxies.apache.http.HttpClientBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.util.EntityUtils;
import org.jboss.logging.Logger;

@Path("/servicea")
public class ServiceAResource {

    private static final Logger logger = Logger.getLogger(ServiceAResource.class);
    
    @ConfigProperty(name="serviceb.url")
    String serviceBUrl;

    @ConfigProperty(name="servicec.url")
    String serviceCUrl;

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        
        try ( CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {

            HttpGet httpGet = new HttpGet(serviceBUrl);
            try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
                String text = EntityUtils.toString( response.getEntity() );
                logger.info("Response from Service-B : " + text);
            }
            
            httpGet = new HttpGet(serviceCUrl);
            try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
                String text = EntityUtils.toString( response.getEntity() );
                logger.info("Response from Service-C : " + text);
            }

        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return "Hello RESTEasy";

    }
}
```

HttpClientBuilderがAWS X-Ray SDK for Javaで提供されているクラスで、このクラスを使用してHttpClientを作成します。作成したHttpClientはApache HttpClientと同様に使用することができます。 (なお、serviceBUrlとserviceCUrlはプロパティファイルから取得するように設定しています。)

```properties:application.properties
serviceb.url=http://qiita-ecs-service-b.local:8080/serviceb
servicec.url=http://qiita-ecs-service-c.local:8080/servicec
```

### 結果の確認
改修した Service-A のコンテナイメージを作成し、ECSにデプロイします。そして、Cloud9でcurlコマンドを実行し、リクエストを送信します。

```bash
$ curl http://qiita-ecs-service-a.local:8080/servicea
```

次にX-Rayのコンソールを開き、トレース情報を確認すると、以下のような呼出関係が確認できます。
![4-4-3_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/402098cd-7754-5810-cc2f-2b05ccf01099.png)
![4-4-3_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/1b5ddd4c-704e-f036-3df6-9348021c8705.png)

## ログへのトレースIDの出力
最後に、ログにトレースIDを出力し、各サービスが出力したログを紐づけて確認できるようにします。なお、この手順は3つのサービス全てに実施する必要があります。

### 依存性の追加
ログへのトレースIDの出力は、MDC (Mapped Diagnostic Context) にトレースIDを挿入することで実現します。これはX-Ray SDK for Javaから提供されているSegmentListenerで実現するため、まず、dependencies配下に必要な依存関係を追加します。

```xml:pom.xml
<?xml version="1.0"?>
  ...
  <dependencies>
    ...
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-xray-recorder-sdk-slf4j</artifactId>
    </dependency>
  </dependencies>
  ...
```

### ServletContextListenerの実装
次に、ServletContextListenerを実装したクラスを作成し、SegmentListenerを組み込んだX-Ray Recorderを登録する処理を実装します。

```Java:XrayListener.java
package sample.xray.application.listener;

import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import com.amazonaws.xray.AWSXRay;
import com.amazonaws.xray.AWSXRayRecorderBuilder;
import com.amazonaws.xray.slf4j.SLF4JSegmentListener;

public class XrayListener implements ServletContextListener {
    
    @Override
    public void contextInitialized(ServletContextEvent event) {
        AWSXRayRecorderBuilder builder = 
            AWSXRayRecorderBuilder
                .standard()
                .withSegmentListener( new SLF4JSegmentListener());
        AWSXRay.setGlobalRecorder(builder.build());
    }
    
    @Override
    public void contextDestroyed(ServletContextEvent event){
        //Do nothing
    }
}
```

作成したリスナーをweb.xmlに設定します。

```xml:web.xml
<web-app>
  ...
  <listener>
    <listener-class>sample.xray.application.listener.XrayListener</listener-class>
  </listener>
</web-app>
```

### ログフォーマットの定義
最後に、MDCに挿入したトレースIDをログに出力するために、application.propertiesにログフォーマットを定義します。

```properties:application.properties
quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss.SSS} %-6p [%c{3.}#%M] [%X{AWS-XRAY-TRACE-ID}] %m %n
```

これで、アプリケーションの改修は完了です。なお、デフォルトのResourceクラスにはログを出力する処理が含まれていないため、ログの出力処理を実装するのを忘れないでください。

### 結果の確認
各サービスのコンテナイメージを作成し、ECSにデプロイします。そして、Cloud9でcurlコマンドを実行し、リクエストを送信します。

```bash
$ curl http://qiita-ecs-service-a.local:8080/servicea
```

ECSのコンソールからService-Aのログを参照すると、トレースID(1-618b5ac8-c32cea0094537acc6dd2bc50)が出力されていることが確認できます。
![4-5-2_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/2e89d2f7-ef00-140d-1ca2-8caf9252709d.png)

### CloudWatch Logs Insightsからの検索
最後に、同一リクエストに対して、各サービスが出力したログを紐づけて確認します。

ECSのデフォルトの設定では、ログはCloudWatch Logsに送信されます。この際、タスク定義ごとに異なるロググループに送信されるため、タスク定義を跨ってログを検索するためにはCloudWatch Logs Insightsを使用します。CloudWatch Logs Insightsでは、まず、以下のようにロググループの選択で複数のロググループを選択します。
![4-5-2_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/b5c3ee7f-7cac-a89e-2a7b-98ef9733f726.png)
次に、以下のように検索条件を指定して実行します。

```
fields @message
| filter @message like "1-618b5ac8-c32cea0094537acc6dd2bc50"
| sort @timestamp asc
```

結果は以下のようになります。同一リクエストによってService-A、Service-B、Service-C から出力されたログが時系列に表示されていることが確認できます。
![4-5-2_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2196471/003a91ac-32cd-62c0-d37f-8c2a853c1889.png)

# まとめ
以上、AWS X-Ray を使用して、Javaアプリケーションの分散トレーシングを実現する方法を紹介しました。次回の記事では、Node.js 編をお届けします。

# 参考資料
本記事に執筆にあたっては以下の資料を参考にいたしました。

* AWS X-Ray デベロッパーガイド https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/aws-xray.html
* Amazon ECS 開発者ガイド https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/Welcome.html
* Quarkus https://quarkus.io/

---
* Amazon Web Services、および、その他のAWS 商標は、米国およびその他の諸国におけるAmazon.com, Inc.またはその関連会社の商標です。
* Javaは、Oracle Corporation、および、その子会社、関連会社の米国及びその他の国における登録商標です。
* その他、本資料に記述してある会社名、製品名は、各社の登録商品または商標です。