Application Signalsで実現するJavaアプリオブザーバビリティ
導入
背景・目的
- 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
注意事項
- 本記事は万全を期して作成していますが、お気づきの点がありましたら、ご連絡よろしくお願いします。
- なお、本記事の内容を利用した結果及び影響について、筆者は一切の責任を負いませんので、予めご了承ください。
Discussion