🔭

Application Signalsで実現するJavaアプリオブザーバビリティ

2025/01/05に公開

導入

背景・目的

  • 2024年6月にGAとなったOpenTelemetry互換のAPMであるApplication Signals を利用することで、AWSで稼働するアプリケーションの自動計測やSLO追跡が可能となります。
  • Application Signals の GA によって、AWS サービス単体でのオブザーバビリティ実現へのハードルが下がった印象があります。
  • 本ブログでは ADOT や Application Signals の概要を説明したうえで、Application Signalsを実際に設定してJava アプリケーションに対するオブザーバビリティを実践してみます。

対象読者

  • AWS Certified Solutions Architect - Professionalレベルの知識を想定し、ADOT や Application Signals 以外のサービスに対する詳細な説明は割愛します。

環境構成図

ADOT 概要

Application Signalsの概要説明に先んじて、アプリケーションの計測時に利用するAWS Distro for OpenTelemetry(以降、ADOT)の概要を説明します。

前提として、OpenTelemetryとは、メトリクス・ログ・トレースなどのテレメトリデータを収集するためのオブザーバビリティフレームワークであり、標準仕様・ライブラリ・エージェント等を提供します。
ADOTとはAWSがサポートするOpenTelemetryのディストリビューションです。ADOTを利用することで、アプリケーションを計測してメトリクスやトレースをCloudWatch・X-Ray等のAWSサービスに送信することができます。

Application Signals 概要

Application SignalsとはOpenTelemetry互換のAPMです。Application Signals を利用することで、AWSで稼働するアプリケーションの自動計測やSLO追跡が可能となります。

  • ADOTを用いて、メトリクス・トレース収集
  • CloudWatch Agentを用いて、CloudWatch・X-Rayへデータ転送
    • OpenTelemetryに対応し、OTLPをサポート
    • 旧来のCloudWatch Agent、X-Ray daemon、OpenTelemetry Collectorの役割を果たす
  • Application Signalsコンソール上で可視化・SLO追跡

また、CloudWatch RUMやCanaryと連携したダッシュボード表示も可能です。(本ブログでも挙動検証してみます。)

ADOT と Application Signals を用いた オブザーバビリティ実践

再掲: 環境構成図

バックエンドアプリケーションの構築

OpenTelemetryのSpring Boot starterを用いて、自動計装します。

pom.xmlにDependencyを定義してください。

<dependency>
	<groupId>io.opentelemetry.instrumentation</groupId>
	<artifactId>opentelemetry-instrumentation-bom</artifactId>
	<version>2.11.0</version>
	<type>pom</type>
	<scope>import</scope>
</dependency>

Java Spring Bootを用いて、S3や外部サイトへリクエストするよう実装しています。

また、「フロントエンドアプリケーションからのリクエスト」や「RUMと連携した一気通貫のトレース」を実現するために、CORS設定しています。X-Amzn-Trace-Idヘッダを許可するのがポイントです。(なお、検証用にlocalhostを許可している点に留意ください。)

package com.example.demo.controller;

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "http://localhost", allowedHeaders = {
        "Content-Type", 
        "Authorization", 
        "X-Amzn-Trace-Id", 
        "Accept", 
        "Origin", 
        "X-Requested-With", 
        "Access-Control-Allow-Origin", 
        "Access-Control-Allow-Headers"
    }) // 検証用のためコード内で直接指定
public class MultiEndpointController {

    private final RestTemplate restTemplate;
    private final S3Client s3Client;

    @Value("${s3.bucket-name}")
    private String bucketName;

    @Value("${s3.file-name}")
    private String fileName;

    public MultiEndpointController() {
        this.restTemplate = new RestTemplate();
        // 東京リージョンをデフォルトとして S3Client を作成
        this.s3Client = S3Client.builder()
                .region(Region.AP_NORTHEAST_1)
                .build();
    }

    @GetMapping("/hello")
    public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
        return "Hello, " + name + "!";
    }

    @GetMapping("/checkip")
    public String checkIp() {
        String url = "https://checkip.amazonaws.com/";
        try {
            return restTemplate.getForObject(url, String.class);
        } catch (Exception e) {
            return "Failed to fetch IP";
        }
    }

    @GetMapping("/s3")
    public ResponseEntity<Map<String, Object>> getFileContentFromS3() {
        try {
            // 指定したバケットとファイルの内容を取得
            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                    .bucket(bucketName)
                    .key(fileName)
                    .build();

            String fileContent = new BufferedReader(
                    new InputStreamReader(s3Client.getObject(getObjectRequest))
            ).lines().collect(Collectors.joining("\n"));

            // JSON レスポンスの作成
            Map<String, Object> response = Map.of(
                    "bucket", bucketName,
                    "file", fileName,
                    "content", fileContent
            );

            return ResponseEntity.ok(response);

        } catch (Exception e) {
            Map<String, Object> errorResponse = Map.of(
                "error", "Failed to retrieve file from S3",
                "message", e.getMessage()
            );
            return ResponseEntity.internalServerError().body(errorResponse);
        }
    }
}

バックエンドアプリケーションの実行用に、簡易的なDockerfileを用意します。

プロダクション環境では、必要に応じてマルチステージビルド等を設定してください。(今回は検証用のため、別途イメージ外でビルドしたjarをCOPYしています。)

FROM amazoncorretto:17

WORKDIR /work

COPY target/demo-0.0.1-SNAPSHOT.jar app.jar

ENTRYPOINT [ "java", "-jar", "app.jar" ]

Application Signals 初期設定

AWS コンソールのCloudWatch Application Signals>ステップ 1. サービス検出の 1 回限りの設定から、サービスを自動的に検出できるよう、必要なサービスアクセス許可を付与します。

CDK を用いた AWS 資源の構築

まずは、ネットワーク・ALB・ECS 資源を構築していきます。

  • AWS公式ドキュメントの記載に従って、Application Signals向けにコンテナ設定するのがポイントです。
  • initContainerより先にjavaAppが立ち上がってしまった起因のエラーを経験したので、依存関係を定義しています。
  • ネットワーク資源構築にはBleaのコンストラクトを利用していますが、SG等は適宜設定してください。
// ------------ Network ---------------
// Create VPC Resources by using Blea
// https://github.com/aws-samples/baseline-environment-on-aws/blob/main/usecases/blea-guest-ecs-app-sample/lib/construct/networking.ts
const networking = new Networking(this, 'Networking', {
  vpcCidr: props.vpcCidr,
});
const vpc = networking.vpc

// ---- Security Group
// SG for ALB
const albSg = new ec2.SecurityGroup(this, "AlbSg", {
  vpc,
});
albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));

// SG for ECS
const ecsSg = new ec2.SecurityGroup(
  this,
  "EcsSg",
  {
    vpc,
    allowAllOutbound: false,
  },
);
ecsSg.addIngressRule(
  albSg,
  ec2.Port.tcp(8080),
);
ecsSg.addEgressRule(
  ec2.Peer.anyIpv4(),
  ec2.Port.allTcp(),
);

// ------------ Backend Application ---------------
// ---- ECS
// Create IAM Resources
const executionRole = new iam.Role(this, "ExecutionRole", {
  assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName(
      "service-role/AmazonECSTaskExecutionRolePolicy",
    ),
    iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMFullAccess"),
    iam.ManagedPolicy.fromAwsManagedPolicyName(
      "CloudWatchAgentServerPolicy",
    ),
  ],
});
const taskRole = new iam.Role(this, "TaskRole", {
  assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonS3ReadOnlyAccess"),
  ],
  inlinePolicies: {
    CustomPolicy: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "logs:PutLogEvents",
            "logs:CreateLogGroup",
            "logs:CreateLogStream",
            "logs:DescribeLogStreams",
            "logs:DescribeLogGroups",
            "logs:PutRetentionPolicy",
            "xray:PutTraceSegments",
            "xray:PutTelemetryRecords",
            "xray:GetSamplingRules",
            "xray:GetSamplingTargets",
            "xray:GetSamplingStatisticSummaries",
            "cloudwatch:PutMetricData",
            "ec2:DescribeVolumes",
            "ec2:DescribeTags",
            "ssm:GetParameters"
          ],
          resources: ["*"]
        })
      ]
    })
  }
});

// Define ECR
const ecrRepo = ecr.Repository.fromRepositoryArn(this, "EcrRepo", props.ecrRepoArn)

// Create ECS Cluster
const cluster = new ecs.Cluster(this, "Cluster", {
  vpc: networking.vpc,
  containerInsights: true,
})

// Create Fargate task definition
const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDefinition", {
  memoryLimitMiB: 512,
  cpu: 256,
  executionRole,
  taskRole,
});
taskDefinition.addVolume({
  name: "opentelemetry-auto-instrumentation",
});

// Add CloudWatch Agent Container
const cloudWatchAgent = taskDefinition.addContainer("CloudWatchAgent", {
  image: ecs.ContainerImage.fromRegistry("public.ecr.aws/cloudwatch-agent/cloudwatch-agent:latest-amd64"),
  logging: ecs.LogDriver.awsLogs({
    streamPrefix: "CloudWatchAgent",
    logGroup: new logs.LogGroup(this, "CloudWatchAgentLogGroup", {
      retention: cdk.aws_logs.RetentionDays.ONE_WEEK,
    }),
  }),
  environment: {
    CW_CONFIG_CONTENT: '{"agent": {"debug": true}, "traces": {"traces_collected": {"application_signals": {"enabled": true}}}, "logs": {"metrics_collected": {"application_signals": {"enabled": true}}}}',
  },
});

// Add ADOT Container
const initContainer = taskDefinition.addContainer("InitContainer", {
  image: ecs.ContainerImage.fromRegistry(
    "public.ecr.aws/aws-observability/adot-autoinstrumentation-java:v1.32.6",
  ),
  essential: false,
  command: [
    "cp",
    "/javaagent.jar",
    "/otel-auto-instrumentation/javaagent.jar"
  ],
})
initContainer.addMountPoints({
  sourceVolume: "opentelemetry-auto-instrumentation",
  containerPath: "/otel-auto-instrumentation",
  readOnly: false,
});

// Add Java Container
const javaApp = taskDefinition.addContainer(
  "JavaApp",
  {
    image: ecs.ContainerImage.fromEcrRepository(
      ecrRepo,
      props.imageTag,
    ),
    logging: ecs.LogDriver.awsLogs({
      streamPrefix: "JavaApp",
      logGroup: new logs.LogGroup(this, "JavaAppLogGroup", {
        logGroupName: `/ecs/${id}/java-app`,
        retention: cdk.aws_logs.RetentionDays.ONE_WEEK,
      }),
    }),
    environment: {
      OTEL_RESOURCE_ATTRIBUTES: "service.name=java_app",
      OTEL_LOGS_EXPORTER: "none",
      OTEL_METRICS_EXPORTER: "none",
      OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
      OTEL_AWS_APPLICATION_SIGNALS_ENABLED: "true",
      JAVA_TOOL_OPTIONS: " -javaagent:/otel-auto-instrumentation/javaagent.jar",
      OTEL_AWS_APPLICATION_SIGNALS_EXPORTER_ENDPOINT: "http://localhost:4316/v1/metrics",
      OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://localhost:4316/v1/traces",
      OTEL_TRACES_SAMPLER: "xray",
      OTEL_PROPAGATORS: "tracecontext,baggage,b3,xray"
    },
    essential: true,
  },
);
javaApp.addPortMappings({
  containerPort: 8080,
  hostPort: 8080,
  protocol: ecs.Protocol.TCP,
});
javaApp.addMountPoints({
  sourceVolume: "opentelemetry-auto-instrumentation",
  containerPath: "/otel-auto-instrumentation",
  readOnly: false,
});
taskDefinition.defaultContainer = javaApp
javaApp.addContainerDependencies({ container: initContainer, condition: ecs.ContainerDependencyCondition.START })

// Create Service
const service = new ecs.FargateService(
  this,
  "Service",
  {
    cluster,
    vpcSubnets: vpc.selectSubnets({ subnetGroupName: "Private" }),
    securityGroups: [ecsSg],
    taskDefinition: taskDefinition,
    desiredCount: 1,
    enableExecuteCommand: true,
    circuitBreaker: {
      enable: true,
    },
  },
);

// ---- ALB
const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
  vpc,
  internetFacing: true,
  securityGroup: albSg,
  vpcSubnets: vpc.selectSubnets({
    subnetGroupName: "Public",
  }),
});
const albListener = alb.addListener("AlbListener", {
  port: 80,
});

const targetGroup = albListener.addTargets(
  "TargetGroup",
  {
    port: 8080,
    protocol: elbv2.ApplicationProtocol.HTTP,
    targets: [service],
    healthCheck: {
      enabled: true,
      path: "/api/hello",
      healthyHttpCodes: "200",
    },
  },
);

次に、合成モニタリング用のCanary資源を構築します。

  • Application Signalsと連携するために、X-Ray アクティブトレースマップを有効化してください。
  • 環境変数を用いて監視対象URLを指定しています。
  • Canary用Lambda資源は、assets/canary/nodejs/node_modules/配下に配置しています。
    // ---- Canary
    const canary = new synthetics.Canary(this, "MyCanary", {
      canaryName: "java_app",
      schedule: synthetics.Schedule.rate(cdk.Duration.minutes(3)),
      test: synthetics.Test.custom({
        code: synthetics.Code.fromAsset(path.join(__dirname, "../../assets/canary")),
        handler: "index.handler",
      }),
      runtime: synthetics.Runtime.SYNTHETICS_NODEJS_PUPPETEER_6_2,
      memory: cdk.Size.mebibytes(1024),
      environmentVariables: {
        URL: `http://${alb.loadBalancerDnsName}/api/hello`,
      },
      activeTracing: true
    });

最後にクライアントモニタリング用のRUM資源を構築します。

  • こちらもX-Ray アクティブトレースを有効化してください。
  • また、明示的なIdentityPoolの構築もお忘れなきよう留意ください。
    // ---- RUM
    // Create Cognito IdentityPool
    const myIdentityPool = new IdentityPool(this, "MyIdentityPool", {
      allowUnauthenticatedIdentities: true
    })
    const unauthenticatedRole = myIdentityPool.unauthenticatedRole

    // Create AppMonitor
    const myCfnAppMonitor = new rum.CfnAppMonitor(this, "MyCfnAppMonitor", {
      name: `poc-rum-${id}`,
      domain: "localhost", // 検証用のためコード内で直接指定
      appMonitorConfiguration: {
        enableXRay: true,
        identityPoolId: myIdentityPool.identityPoolId,
        guestRoleArn: unauthenticatedRole.roleArn,
        sessionSampleRate: 1
      },
      cwLogEnabled: true,
    })
    unauthenticatedRole.addToPrincipalPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ["rum:PutRumEvents"],
      resources: [`arn:aws:rum:${props.env!.region}:${props.env!.account}:appmonitor/${myCfnAppMonitor.name}`],
    }));

構築完了後にAWS コンソールにアクセスして、アプリケーションモニターの設定画面から、JavaScriptスニペット(HTML)をコピーしておきましょう。

フロントエンドアプリケーションの構築

簡単なHTMLとJavaScriptを用いて、フロントエンドアプリケーションを構築します。

ボタン押下によってバックエンドアプリケーションの各エンドポイントを呼び出すように実装します。

また、先ほどコピーしたJavaScriptスニペット(HTML)を貼り付けておきます。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>API Frontend</title>
    <!--  RUMのJavaScriptスニペット(HTML)を貼り付けてください -->
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
        }
        #output {
            margin-top: 20px;
            padding: 10px;
            border: 1px solid #ccc;
            background-color: #f9f9f9;
        }
        button {
            margin-right: 10px;
            padding: 10px 20px;
            font-size: 16px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <h1>API Frontend</h1>
    <button id="checkIpButton">Check IP</button>
    <button id="callS3Button">Call S3</button>
    <div id="output">Results will appear here...</div>

    <script>
        const checkIpButton = document.getElementById('checkIpButton');
        const callS3Button = document.getElementById('callS3Button');
        const outputDiv = document.getElementById('output');

        // Helper function to display results
        const displayOutput = (data) => {
            outputDiv.textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
        };

        // Check IP Button: Fetch IP from /api/checkip
        checkIpButton.addEventListener('click', () => {
            fetch('http://example.com/api/checkip')
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`Error: ${response.status} ${response.statusText}`);
                    }
                    return response.text();
                })
                .then(data => displayOutput(data))
                .catch(error => displayOutput(`Failed to fetch IP: ${error.message}`));
        });

        // Call S3 Button: Fetch S3 data from /api/s3
        callS3Button.addEventListener('click', () => {
            fetch('http://example.com/api/s3')
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`Error: ${response.status} ${response.statusText}`);
                    }
                    return response.json();
                })
                .then(data => displayOutput(data))
                .catch(error => displayOutput(`Failed to fetch S3 data: ${error.message}`));
        });
    </script>
</body>
</html>

RUMと連携した一気通貫のトレースを実現するために、JavaScriptスニペット(HTML)を修正します。telemetries部分を修正して、HTTPヘッダにX-Amzn-Trace-Idを追加するように設定しています。

telemetries: [
            "performance",
            "errors",
            [
              "http",
              {
                addXRayTraceIdHeader: true,
              },
            ],
          ],

フロントエンドアプリケーションの配信用に、簡易的なDockerfileを用意します。

FROM httpd:2.4.62 

COPY index.html /usr/local/apache2/htdocs/

EXPOSE 80

動作確認

アプリケーションやAWS資源構築が完了したので、動作確認してみましょう。

まずはローカルでdocker runコマンドを実行して、フロントエンドアプリケーション配信用Dockerコンテナを実行します。

  • ボタンを押下すると、Javaアプリケーションが呼び出されることが確認できます。
  • また、開発者ツールのNetworkタブを表示することで、フロントエンドからRUMのウェブクライアント(cwr.js)やCognitoのエンドポイントが呼び出されてることも確認できます。

ここからは、AWSコンソールで動作確認してみます。

CloudWatch Application Signals>Service Mapにアクセスすると、いい感じにサービスマップが表示されています。

JavaバックエンドアプリケーションからのS3・外部URLリクエストだけでなく、CanaryやRUMも表示されていますね。

また、サービスレベル目標 (SLO) > SLOを作成にアクセスすると、新規作成したサービスに対して可用性・レイテンシーに関するSLOを設定できるようになっていました。

参考

アプリケーションモニタリング (APM) 用の Amazon CloudWatch Application Signals の一般提供を開始 - AWS
AWS Distro for OpenTelemetry と AWS X-Ray - AWS X-Ray
pages.awscloud.com
pages.awscloud.com
Amazon CloudWatch Application Signalsの全貌
CloudWatch Application Signals と APM の入門
Amazon CloudWatch Application Signals(Preview) 徹底解説
[アップデート]Amazon CloudWatch Application SignalsがGAしました!CDKでサンプル作ってみた | DevelopersIO
合成モニタリング (canary) - Amazon CloudWatch
Node.js Canary スクリプトの記述 - Amazon CloudWatch
class IdentityPool (construct) · AWS CDK
【CloudWatch RUM × X-Ray】フロントエンドからバックエンドを一気通貫でトレースする方法 | DevelopersIO
CloudWatch RUM と AWS X-Ray - AWS X-Ray

注意事項

  • 本記事は万全を期して作成していますが、お気づきの点がありましたら、ご連絡よろしくお願いします。
  • なお、本記事の内容を利用した結果及び影響について、筆者は一切の責任を負いませんので、予めご了承ください。
Accenture Japan (有志)

Discussion