1. はじめに
Spring BootなどのJavaアプリケーションをAWS Fargateで運用している環境で、分散トレーシングを実現するためにAWS X-Rayの導入を検討しているケースは多いのではないでしょうか。
従来のX-Ray導入方法では、アプリケーションコードの変更が必要でした。具体的には、AWS X-Ray SDKをアプリケーションに組み込み、トレースするコードを各所に追加するという作業が発生します。これはアプリケーション開発チームへの負担が大きく、既存システムへの導入ハードルが高いという課題がありました。
しかし、AWS Distro for OpenTelemetry (ADOT) を使用した自動計装という方法を使えば、アプリケーションコードを一切変更することなく、X-Rayによるトレーシングを実現できます。この記事では、その導入方法を具体的な手順とともに紹介します。
注意: 現時点で自動計装をサポートしているのはJavaとPythonのみです。
2. AWS Distro for OpenTelemetry (ADOT)を使用したトレース取得のポイント
2.1 ADOTとは何か?
AWS Distro for OpenTelemetry (ADOT)は、オープンソースのObservabilityフレームワークであるOpenTelemetryのAWS向けディストリビューションです。テレメトリデータ(トレース、メトリクス、ログ)の収集と転送を一貫した方法で行うことができます。
2.2 自動計装のメリット
- 導入負荷の軽減: アプリケーションコードを変更せずにテレメトリデータの取得ができる
- 標準化: OpenTelemetryという業界標準ベースの計装方法を採用
2.3 実装のための主要コンポーネント
ADOTを使ったJavaアプリケーションの自動計装を実現するためには、以下の4つの主要コンポーネントを正しく設定する必要があります。
今回はトレースに絞って記述します。
2.3.1 Dockerfile
Dockerfileでは、以下の重要な設定を行います。
# ADOT Javaエージェントのダウンロード
ADD https://github.com/aws-observability/aws-otel-java-instrumentation/releases/download/v1.33.0/aws-opentelemetry-agent.jar ./aws-opentelemetry-agent.jar
# Javaエージェントとして読み込み設定
ENV JAVA_TOOL_OPTIONS "-javaagent:/adot-java-sample/aws-opentelemetry-agent.jar"
# OpenTelemetry関連の環境変数設定
ENV OTEL_RESOURCE_ATTRIBUTES "service.name=xraytestapp" # サービス名の定義
ENV OTEL_IMR_EXPORT_INTERVAL "60000" # メトリクスエクスポート間隔(ms)
ENV OTEL_EXPORTER_OTLP_ENDPOINT "http://127.0.0.1:4317" # OTLPエンドポイント(サイドカーへ)
ENV OTEL_METRICS_EXPORTER "none" # メトリクスエクスポーターの無効化
ENV OTEL_TRACES_EXPORTER="otlp" # トレースエクスポーターの設定
ポイント:
-
-javaagent
オプションで自動計装エージェントを適用 - ローカルホスト(127.0.0.1)のOTLPエンドポイントはサイドカーとして動作するADOT Collectorへの接続先
2.3.2 ECSタスク定義
タスク定義では、アプリケーションコンテナに加えて、ADOT Collectorをサイドカーとして追加します。
{
"name": "aws-otel-collector",
"image": "public.ecr.aws/aws-observability/aws-otel-collector:latest",
"essential": true,
"secrets": [
{
"name": "AOT_CONFIG_CONTENT",
"valueFrom": "otel-collector-config"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/ecs-aws-otel-sidecar-collector",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "aws-otel-collector"
}
}
}
ポイント:
- サイドカーパターンでADOT Collectorをデプロイ(同一タスク内で起動)
- SSMパラメータストアから設定を取得する環境変数
AOT_CONFIG_CONTENT
の指定 - サイドカーが収集したトレースデータをAWS X-Rayに転送
2.3.3 ADOT Collector設定(SSMパラメータストア)
SSMパラメータストアに保存する設定ファイルでは、トレースの収集方法とX-Rayへの送信方法を定義します。
receivers:
otlp:
protocols:
grpc: # アプリケーションから受信するgRPCプロトコル
http: # アプリケーションから受信するHTTPプロトコル
processors:
batch: # バッチ処理でトレースを効率的に送信
memory_limiter:
limit_mib: 100 # メモリ使用量制限
check_interval: 5s # チェック間隔
exporters:
awsxray:
region: ap-northeast-1 # X-Rayへエクスポートするリージョン
service:
pipelines:
traces: # トレースパイプラインの定義
receivers: [otlp]
processors: [memory_limiter,batch]
exporters: [awsxray]
ポイント:
-
otlp
レシーバーでアプリケーションからトレースを受信 -
awsxray
エクスポーターでAWS X-Rayにトレースを転送 - パイプラインでデータの流れを定義(受信→処理→送信)
2.3.4 IAM権限設定
適切な権限設定が必要です。
-
タスクロール:
-
AWSXRayDaemonWriteAccess
マネージドポリシーを追加 - ADOT CollectorがX-Rayにトレースデータを書き込むために必要
-
-
タスク実行ロール:
- SSMパラメータストアへのアクセス権限を追加
- 以下のようなIAMポリシーが必要
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "ssm:GetParameters" ], "Resource": "arn:aws:ssm:ap-northeast-1:*:parameter/otel-collector-config" } ] }
ポイント:
- タスクロールはタスク実行中に使用される権限
- タスク実行ロールはタスク起動時に使用される権限
- 最小権限の原則に基づいて必要な権限のみを付与
2.4 実装フロー
これらのコンポーネントを組み合わせた実装フローは以下のようになります。
- DockerfileでADOTエージェントを含むコンテナイメージをビルド
- SSMパラメータストアにOTEL Collector設定を保存
- ECSタスク定義でアプリケーションコンテナとADOT Collectorサイドカーを定義
- 必要なIAM権限を設定
- ECSサービスをデプロイ
この実装により、アプリケーションコードを変更することなく、JVMレベルでの自動計装が実現され、トレースデータがX-Rayに送信されるようになります。
3. 実際にやってみた
3.1 AWS環境を準備する
必要なリソースは以下です。
- VPCとサブネット
- セキュリティグループ
- ALB(Application Load Balancer)
- ECSクラスター
カスタムドメインを活用する場合
- ACM
- Route53
上記環境を構築するCloudFormationテンプレートを以下に用意しているので必要な方は活用してください。
(※Route53とACMは事前に用意してください。)
Nwtwork.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: >
AWS Resources: Template for NetworkResource
Parameters:
SystemName:
Description: SystemName
Type: String
Default: System Name
Resources:
#--------------------------------------------------
# - ManagedPolicy
#--------------------------------------------------
AllowFlowLogsPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: !Join ["-", [!Ref SystemName, allow, flowlogs, policy]]
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
- logs:DescribeLogGroups
- logs:DescribeLogStreams
Resource: '*'
Roles:
- !Ref 'FlowLogsRole'
Path: "/"
#--------------------------------------------------
# - Role
#--------------------------------------------------
FlowLogsRole:
Type: AWS::IAM::Role
DeletionPolicy: Delete
Properties:
RoleName: !Join ["-", [!Ref SystemName, flowlogs, role]]
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- vpc-flow-logs.amazonaws.com
Action: sts:AssumeRole
#--------------------------------------------------
# - VPC
#--------------------------------------------------
vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/20
InstanceTenancy: default
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, vpc]]
- Key: System
Value: !Ref SystemName
#--------------------------------------------------
# - Flowlog
#--------------------------------------------------
VPCFlowLog:
Type: AWS::EC2::FlowLog
Properties:
DeliverLogsPermissionArn: !GetAtt FlowLogsRole.Arn
LogDestinationType: cloud-watch-logs
LogGroupName: !Ref LogGroup
LogFormat: '${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status} ${vpc-id} ${subnet-id} ${instance-id} ${tcp-flags} ${type} ${pkt-srcaddr} ${pkt-dstaddr} ${az-id} ${sublocation-type} ${sublocation-id} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path}'
ResourceId: !Ref vpc
ResourceType: VPC
TrafficType: ALL
#--------------------------------------------------
# - LogGroup
#--------------------------------------------------
LogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Join [ "", [/aws/vpc/,!Ref SystemName]]
RetentionInDays: 365
#--------------------------------------------------
# - Network ACL
#--------------------------------------------------
nacl:
Type: AWS::EC2::NetworkAcl
Properties:
VpcId: !Ref vpc
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, nacl]]
- Key: System
Value: !Ref SystemName
naclentry01:
Type: AWS::EC2::NetworkAclEntry
Properties:
NetworkAclId: !Ref nacl
RuleNumber: 100
Protocol: -1
RuleAction: allow
Egress: false
CidrBlock: 0.0.0.0/0
naclentry02:
Type: AWS::EC2::NetworkAclEntry
Properties:
NetworkAclId: !Ref nacl
RuleNumber: 100
Protocol: -1
RuleAction: allow
Egress: true
CidrBlock: 0.0.0.0/0
#--------------------------------------------------
# - DHCP Option Set
#--------------------------------------------------
dhcpoptions:
Type: AWS::EC2::DHCPOptions
Properties:
DomainName: !Join ["", [!Sub '${AWS::Region}', ".compute.internal"]]
DomainNameServers:
- AmazonProvidedDNS
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, dhcp]]
- Key: System
Value: !Ref SystemName
dhcpoptionsassosiation:
Type: AWS::EC2::VPCDHCPOptionsAssociation
Properties:
VpcId: !Ref vpc
DhcpOptionsId: !Ref dhcpoptions
#--------------------------------------------------
# - Internet Gateway(IGW)
#--------------------------------------------------
IGW:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, igw]]
- Key: System
Value: !Ref SystemName
AttachmentIGW:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
InternetGatewayId: !Ref IGW
VpcId: !Ref vpc
#--------------------------------------------------
# - Subnet
#--------------------------------------------------
publicsubnet01:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref vpc
CidrBlock: !Select [ 0, !Cidr [ 10.0.0.0/20, 12, 8 ]]
AvailabilityZone: !Join ["", [!Sub '${AWS::Region}', "a"]]
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, public, subnet, '01']]
- Key: System
Value: !Ref SystemName
publicsubnet02:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref vpc
CidrBlock: !Select [ 1, !Cidr [ 10.0.0.0/20, 12, 8 ]]
AvailabilityZone: !Join ["", [!Sub '${AWS::Region}', "c"]]
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, public, subnet, '02']]
- Key: System
Value: !Ref SystemName
appsubnet01:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref vpc
CidrBlock: !Select [ 2, !Cidr [ 10.0.0.0/20, 12, 8 ]]
AvailabilityZone: !Join ["", [!Sub '${AWS::Region}', "a"]]
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, private, appsubnet, '01']]
- Key: System
Value: !Ref SystemName
appsubnet02:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref vpc
CidrBlock: !Select [ 3, !Cidr [ 10.0.0.0/20, 12, 8 ]]
AvailabilityZone: !Join ["", [!Sub '${AWS::Region}', "c"]]
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, private, appsubnet, '02']]
- Key: System
Value: !Ref SystemName
#--------------------------------------------------
# - SubnetNetworkAclAssociation
#--------------------------------------------------
publicsubnet01NetworkAclAssociation:
Type: AWS::EC2::SubnetNetworkAclAssociation
Properties:
SubnetId: !Ref publicsubnet01
NetworkAclId: !Ref nacl
publicsubnet02NetworkAclAssociation:
Type: AWS::EC2::SubnetNetworkAclAssociation
Properties:
SubnetId: !Ref publicsubnet02
NetworkAclId: !Ref nacl
appsubnet01NetworkAclAssociation:
Type: AWS::EC2::SubnetNetworkAclAssociation
Properties:
SubnetId: !Ref appsubnet01
NetworkAclId: !Ref nacl
appsubnet02NetworkAclAssociation:
Type: AWS::EC2::SubnetNetworkAclAssociation
Properties:
SubnetId: !Ref appsubnet02
NetworkAclId: !Ref nacl
#--------------------------------------------------
# - Elastic IP(EIP)
#--------------------------------------------------
NGWEIP01:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
NGWEIP02:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
#--------------------------------------------------
# - NAT Gateway(NGW)
#--------------------------------------------------
NGW01:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NGWEIP01.AllocationId
SubnetId: !Ref publicsubnet01
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, ngw01]]
- Key: System
Value: !Ref SystemName
NGW02:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NGWEIP02.AllocationId
SubnetId: !Ref publicsubnet02
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, ngw02]]
- Key: System
Value: !Ref SystemName
#--------------------------------------------------
# - RouteTable
#--------------------------------------------------
publicsubnetRT:
Type: "AWS::EC2::RouteTable"
Properties:
VpcId: !Ref vpc
Tags:
- Key: Name
Value: !Join ["-", [ !Ref SystemName, publicsubnet, rt]]
publicsubnetRTAssociation01:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref publicsubnet01
RouteTableId: !Ref publicsubnetRT
publicsubnetRTAssociation02:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref publicsubnet02
RouteTableId: !Ref publicsubnetRT
appsubnet01RT:
Type: "AWS::EC2::RouteTable"
Properties:
VpcId: !Ref vpc
Tags:
- Key: Name
Value: !Join ["-", [ !Ref SystemName, appsubnet01, rt]]
appsubnet01RTAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref appsubnet01
RouteTableId: !Ref appsubnet01RT
appsubnet02RT:
Type: "AWS::EC2::RouteTable"
Properties:
VpcId: !Ref vpc
Tags:
- Key: Name
Value: !Join ["-", [ !Ref SystemName, appsubnet02, rt]]
appsubnet02RTAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref appsubnet02
RouteTableId: !Ref appsubnet02RT
#--------------------------------------------------
# - Route
#--------------------------------------------------
publicsubnetRTRoute01:
Type: "AWS::EC2::Route"
Properties:
RouteTableId: !Ref publicsubnetRT
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref IGW
appsubnet01RTRoute01:
Type: "AWS::EC2::Route"
Properties:
RouteTableId: !Ref appsubnet01RT
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NGW01
appsubnet02RTRoute01:
Type: "AWS::EC2::Route"
Properties:
RouteTableId: !Ref appsubnet02RT
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NGW02
# ------------------------------------------------------------#
# Create s3 End Point for appbucket and docker
# ------------------------------------------------------------#
ecss3Endpoint:
Type: 'AWS::EC2::VPCEndpoint'
Properties:
ServiceName: !Join ["",["com.amazonaws.", !Sub '${AWS::Region}',".s3"]]
RouteTableIds:
- !Ref appsubnet01RT
- !Ref appsubnet02RT
VpcEndpointType: Gateway
VpcId:
!Ref vpc
PolicyDocument: {
"Version": "2008-10-17",
"Statement": [
{
"Sid": "Access-to-specific-bucket-only",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"s3:HeadObject",
"s3:DeleteObject"
],
"Resource": "*"
}
]
}
# ------------------------------------------------------------#
# Create SG
# ------------------------------------------------------------#
albsg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName:
Fn::Join: ["-", [!Ref SystemName, "alb-sg" ]]
GroupDescription:
Fn::Join: ["-", [!Ref SystemName, "alb-sg" ]]
VpcId: !Ref vpc
Tags:
- Key: System
Value: !Ref SystemName
- Key: Name
Value: !Join ["-", [ !Ref SystemName, "alb-sg" ]]
albIngress01:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !Ref albsg
IpProtocol: TCP
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
Description: Internet
albEgress01:
Type: AWS::EC2::SecurityGroupEgress
Properties:
GroupId: !Ref albsg
IpProtocol: TCP
FromPort: 80
ToPort: 80
DestinationSecurityGroupId: !Ref appsg
Description: app-sg
appsg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName:
Fn::Join: ["-", [!Ref SystemName, "app-sg" ]]
GroupDescription:
Fn::Join: ["-", [!Ref SystemName, "app-sg" ]]
VpcId: !Ref vpc
Tags:
- Key: System
Value: !Ref SystemName
- Key: Name
Value: !Join ["-", [ !Ref SystemName, "app-sg" ]]
appsgIngress01:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !Ref appsg
IpProtocol: TCP
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !Ref albsg
Description: alb-sg
appsgEgress01:
Type: AWS::EC2::SecurityGroupEgress
Properties:
GroupId: !Ref appsg
IpProtocol: TCP
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
Description: Internet
Outputs:
FlowLogsRole:
Value: !GetAtt FlowLogsRole.Arn
Export:
Name: !Join ["-", [!Ref SystemName, flowlogs, role, arn]]
vpcid:
Value: !Ref vpc
Export:
Name: !Join ["-", [!Ref SystemName, vpc, id]]
vpccidr:
Value: !GetAtt vpc.CidrBlock
Export:
Name: !Join ["-", [!Ref SystemName, vpc, cidr]]
naclid:
Value: !Ref nacl
Export:
Name: !Join ["-", [!Ref SystemName, nacl, id]]
IGW:
Value: !Ref IGW
Export:
Name: !Join ["-", [!Ref SystemName, igw, id]]
LogGroup:
Value: !Ref LogGroup
Export:
Name: !Join ["-", [!Ref SystemName, vpc, loggroup, name]]
publicsubnet01:
Value: !Ref publicsubnet01
Export:
Name: !Join ["-", [!Ref SystemName, public, subnet, '01', id]]
publicsubnet02:
Value: !Ref publicsubnet02
Export:
Name: !Join ["-", [!Ref SystemName, public, subnet, '02', id]]
appsubnet01:
Value: !Ref appsubnet01
Export:
Name: !Join ["-", [!Ref SystemName, private, appsubnet, '01', id]]
appsubnet02:
Value: !Ref appsubnet02
Export:
Name: !Join ["-", [!Ref SystemName, private, appsubnet, '02', id]]
NGW01:
Value: !Ref NGW01
Export:
Name: !Join ["-", [!Ref SystemName, ngw01, id]]
NGW02:
Value: !Ref NGW02
Export:
Name: !Join ["-", [!Ref SystemName, ngw02, id]]
publicsubnetRT:
Value: !Ref publicsubnetRT
Export:
Name: !Join ["-", [ !Ref SystemName, publicsubnet, rt, id]]
appsubnet01RT:
Value: !Ref appsubnet01RT
Export:
Name: !Join ["-", [ !Ref SystemName, appsubnet01, rt, id]]
appsubnet02RT:
Value: !Ref appsubnet02RT
Export:
Name: !Join ["-", [ !Ref SystemName, appsubnet02, rt, id]]
albsg:
Value: !Ref albsg
Export:
Name: !Join ["-", [ !Ref SystemName, "alb-sg" ,id]]
appsg:
Value: !Ref appsg
Export:
Name: !Join ["-", [ !Ref SystemName, "app-sg" ,id]]
ALB-ECS.yaml
AWSTemplateFormatVersion: 2010-09-09
Description: Template for app Application ECS and ALB
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
- Label:
default: System Configuration
Parameters:
- SystemName
- Label:
default: ACMCertificateArn
Parameters:
- ACMCertificateArn
- Label:
default: Route53 Configuration
Parameters:
- HostZoneID
- DomainName
- Label:
default: ELBSecurityPolicy
Parameters:
- ELBSecurityPolicy
ParameterLabels:
SystemName:
default: System Name
ACMCertificateArn:
default: ACMCertificateArn
HostZoneID:
default: HostZoneID
ELBSecurityPolicy:
default: ELBSecurityPolicy
DomainName:
default: DomainName
# --------------------------------------------------#
# Input Parameters
# --------------------------------------------------#
Parameters:
SystemName:
Type: String
Description: システム名
ACMCertificateArn:
Description: "Enter the Certificate ARN"
Type: String
Default: none
HostZoneID:
Description: "Select the Route53 hosted zone ID"
Type: AWS::Route53::HostedZone::Id
ELBSecurityPolicy:
Type: String
AllowedValues:
- ELBSecurityPolicy-TLS-1-2-Ext-2018-06
- ELBSecurityPolicy-TLS13-1-2-2021-06
DomainName:
Description: "Enter the Route53 domain name"
Type: String
Default: none
Resources:
#--------------------------------------------------
# - ECR Repository
#--------------------------------------------------
ECR:
Type: AWS::ECR::Repository
Properties:
RepositoryName: !Join ["-", [adot, java, sample]]
ImageScanningConfiguration:
scanOnPush: "true"
ImageTagMutability: "MUTABLE"
Tags:
- Key: Name
Value: !Join ["-", [adot, java, sample]]
- Key: System
Value: !Ref SystemName
#--------------------------------------------------
# - Role
#--------------------------------------------------
ECSTaskExecutionRole:
Type: AWS::IAM::Role
DeletionPolicy: Delete
Properties:
Path: "/"
RoleName: !Join ["-", [!Ref SystemName, ecstaskexecution, role]]
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
- arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- ecs-tasks.amazonaws.com
Action: sts:AssumeRole
ECSTaskRole:
Type: AWS::IAM::Role
DeletionPolicy: Delete
Properties:
Path: "/"
RoleName: !Join ["-", [!Ref SystemName, ecstask, role]]
ManagedPolicyArns:
- arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
- arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- ecs-tasks.amazonaws.com
Action: sts:AssumeRole
#--------------------------------------------------
# - ECS Cluster
#--------------------------------------------------
ECSCluster:
Type: "AWS::ECS::Cluster"
Properties:
ClusterName: !Join ["-", [ !Ref SystemName, ecscluster]]
ClusterSettings:
- Name: "containerInsights"
Value: "enabled"
Tags:
- Key: System
Value: !Ref SystemName
- Key: Name
Value: !Join ["-", [ !Ref SystemName, ecscluster]]
#--------------------------------------------------
# - LogGroup
#--------------------------------------------------
ECSLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName:
!Sub
- '/aws/ecs/${ecstaskname}'
- ecstaskname:
!Join ["-", [!Ref SystemName, container]]
RetentionInDays: 365
#--------------------------------------------------
# - TaskDefinition
#--------------------------------------------------
TaskDefinition:
Type: "AWS::ECS::TaskDefinition"
Properties:
RequiresCompatibilities:
- "FARGATE"
ContainerDefinitions:
- Name: !Join ["-", [!Ref SystemName, container]]
Image:
!Sub
- "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ecrname}:${tag}"
- ecrname: !Ref ECR
tag: "latest"
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref ECSLogGroup
awslogs-region: !Ref "AWS::Region"
awslogs-stream-prefix: !Sub "${SystemName}-container"
MemoryReservation: 1024
PortMappings:
- HostPort: 80
Protocol: tcp
ContainerPort: 80
- Name: aws-otel-collector
Image: public.ecr.aws/aws-observability/aws-otel-collector:latest
Cpu: 0
PortMappings: []
Essential: true
Environment: []
MountPoints: []
VolumesFrom: []
Secrets:
- Name: AOT_CONFIG_CONTENT
ValueFrom: otel-collector-config
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: "/ecs/ecs-aws-otel-sidecar-collector"
awslogs-region: !Ref "AWS::Region"
awslogs-stream-prefix: "aws-otel-collector"
SecretOptions: []
SystemControls: []
Cpu: 1024
Memory: 2048
ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
Family: !Join ["-", [!Ref SystemName, task]]
NetworkMode: awsvpc
Tags:
- Key: Name
Value: !Join ["-", [!Ref SystemName, taskdefinition ]]
TaskRoleArn: !GetAtt ECSTaskRole.Arn
#--------------------------------------------------
# - Service
#--------------------------------------------------
ECSService:
Type: "AWS::ECS::Service"
DependsOn:
- ALBListenerHTTPS
Properties:
Cluster: !Ref ECSCluster
DeploymentController:
Type: "ECS"
DesiredCount: 0
EnableECSManagedTags: true
HealthCheckGracePeriodSeconds: "240"
LaunchType: "FARGATE"
LoadBalancers:
- TargetGroupArn: !Ref TargetGroupResource
ContainerPort: "80"
ContainerName: !Join ["-", [!Ref SystemName, container]]
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: DISABLED
SecurityGroups:
- Fn::ImportValue: !Join ["-", [ !Ref SystemName, app, sg, id]]
Subnets:
- Fn::ImportValue: !Join ["-", [!Ref SystemName, private, appsubnet, '01', id]]
- Fn::ImportValue: !Join ["-", [!Ref SystemName, private, appsubnet, '02', id]]
ServiceName: !Join ["-", [!Ref SystemName, service, ecs]]
TaskDefinition: !Ref TaskDefinition
PlatformVersion: "1.4.0"
SchedulingStrategy: REPLICA
PropagateTags: TASK_DEFINITION
Tags:
- Key: System
Value: !Ref SystemName
- Key: Name
Value: !Join ["-", [!Ref SystemName, service, ecs]]
# --------------------------------------------------#
# TargetGroup
# --------------------------------------------------#
TargetGroupResource:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId:
Fn::ImportValue: !Join ["-", [!Ref SystemName, vpc, id]]
Name: app-target-group
Protocol: HTTP
Port: 80
TargetType: ip
HealthCheckProtocol: HTTP
HealthCheckPath: "/health"
HealthyThresholdCount: 2
UnhealthyThresholdCount: 2
HealthCheckTimeoutSeconds: 20
HealthCheckIntervalSeconds: 30
Matcher:
HttpCode: 200
Tags:
- Key: Name
Value: app-tg
# --------------------------------------------------#
# ALB
# --------------------------------------------------#
ALB:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: app-alb
# パブリックサブネットのみ配置可という意味
Scheme: internet-facing
# ALBを指定
Type: application
Subnets:
- Fn::ImportValue: !Join ["-", [!Ref SystemName, public, subnet, '01', id]]
- Fn::ImportValue: !Join ["-", [!Ref SystemName, public, subnet, '02', id]]
SecurityGroups:
- Fn::ImportValue: !Join ["-", [ !Ref SystemName, alb, sg, id]]
IpAddressType: ipv4
Tags:
- Key: Name
Value: app-alb
# Listener
ALBListenerHTTPS:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ALB
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: !Ref ACMCertificateArn
SslPolicy: !Ref ELBSecurityPolicy
DefaultActions:
- TargetGroupArn: !Ref TargetGroupResource
Order: 1
Type: forward
# --------------------------------------------------#
# Route53
# --------------------------------------------------#
ALBAliasRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostZoneID
Name: !Ref DomainName
Type: A
AliasTarget:
HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID
DNSName: !GetAtt ALB.DNSName
Outputs:
ALBOutputDNSName:
Value: !GetAtt ALB.DNSName
Export:
Name: alb-dnsname
ALBOutputHsotZone:
Value: !GetAtt ALB.CanonicalHostedZoneID
Export:
Name: alb-hostzoneid
すでに環境をお持ちの場合は、この手順をスキップしていただいて構いません。
3.2 SSMパラメータストアにOTEL Collector設定を保存
SSMパラメータストアに以下のコンフィグをotel-collector-config
という名前で保存します。
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
memory_limiter:
limit_mib: 100
check_interval: 5s
exporters:
awsxray:
region: ap-northeast-1
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter,batch]
exporters: [awsxray]
3.3 ECSタスク定義の作成 (3.1項にてCloudFormationで環境を構築した方はスキップしてください)
次に、ECSタスク定義を作成します。ポイントはADOT Collectorをサイドカーとして組み込むことです。
1. アプリケーションコンテナの定義
これは通常通りのコンテナ定義ですが、上記でECRにプッシュしたイメージを使用します。
2. ADOT Collectorサイドカーの追加
{
"name": "aws-otel-collector",
"image": "public.ecr.aws/aws-observability/aws-otel-collector:latest",
"cpu": 0,
"portMappings": [],
"essential": true,
"environment": [],
"mountPoints": [],
"volumesFrom": [],
"secrets": [
{
"name": "AOT_CONFIG_CONTENT",
"valueFrom": "otel-collector-config"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/ecs-aws-otel-sidecar-collector",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "aws-otel-collector"
},
"secretOptions": []
},
"systemControls": []
}
3.4 必要なIAMロールを設定 (3.1項にてCloudFormationで環境を構築した方はスキップしてください)
-
タスクロール:
AWSXRayDaemonWriteAccess
マネージドポリシーを追加 -
タスク実行ロール: SSMパラメータストアから
otel-collector-config
を読み取る権限を追加
3.5 アプリ準備をする
サンプルアプリケーションの概要
今回使用するサンプルアプリケーションは、OpenTelemetryの公式サイトで紹介されている「単純なサイコロAPIを提供するSpring Bootアプリケーション」です。
-
/rolldice
エンドポイント:1~6の乱数を返す -
/health
エンドポイント:ヘルスチェック用
3.5.1 必要なファイルの準備
まず、以下の4つのファイルを用意します。
1. DiceApplication.java(メインアプリケーション)
package otel;
import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DiceApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(DiceApplication.class);
app.setBannerMode(Banner.Mode.OFF);
app.run(args);
}
}
2. RollController.java(APIエンドポイント定義)
package otel;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class RollController {
private static final Logger logger = LoggerFactory.getLogger(RollController.class);
@GetMapping("/rolldice")
public String index(@RequestParam("player") Optional<String> player) {
int result = this.getRandomNumber(1, 6);
if (player.isPresent()) {
logger.info("{} is rolling the dice: {}", player.get(), result);
} else {
logger.info("Anonymous player is rolling the dice: {}", result);
}
return Integer.toString(result);
}
@GetMapping("/health")
public String health() {
return "OK";
}
private int getRandomNumber(int min, int max) {
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
}
3. build.gradle.kts(Gradleビルド定義)
plugins {
java
application
id("org.springframework.boot") version "3.1.3"
id("io.spring.dependency-management") version "1.1.3"
}
sourceSets {
main {
java.setSrcDirs(setOf("."))
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
}
4. Dockerfile(多段ビルド+自動計装設定)
FROM public.ecr.aws/docker/library/gradle:jdk17 AS builder
WORKDIR /adot-java-sample
COPY *.java .
COPY build.gradle.kts .
RUN ["gradle", "assemble"]
FROM gcr.io/distroless/java17-debian11:latest
WORKDIR /adot-java-sample
COPY --from=builder /adot-java-sample/build/libs/ ./build/libs/
ENV SERVER_PORT=80
ADD https://github.com/aws-observability/aws-otel-java-instrumentation/releases/download/v1.33.0/aws-opentelemetry-agent.jar ./aws-opentelemetry-agent.jar
ENV JAVA_TOOL_OPTIONS "-javaagent:/adot-java-sample/aws-opentelemetry-agent.jar"
ENV OTEL_RESOURCE_ATTRIBUTES "service.name=xraytestapp"
ENV OTEL_IMR_EXPORT_INTERVAL "60000"
ENV OTEL_EXPORTER_OTLP_ENDPOINT "http://127.0.0.1:4317"
ENV OTEL_METRICS_EXPORTER "none"
ENV OTEL_TRACES_EXPORTER="otlp"
EXPOSE 80
CMD ["/adot-java-sample/build/libs/adot-java-sample.jar"]
このDockerfileの重要なポイントは
- 多段ビルド: GradleでJavaアプリケーションをビルドした後、distrolessの軽量イメージを使用
- ADOTエージェント追加: GitHubからAWS Distro for OpenTelemetry Javaエージェントをダウンロード
-
Javaエージェント設定:
-javaagent
オプションでADOTエージェントをJVMに読み込ませる - OpenTelemetry設定: 環境変数でサービス名やエンドポイントなどを指定
3.5.2 AWS CloudShellでのビルドとECRへのプッシュ
AWS CloudShellを使用してアプリケーションをビルドし、Amazon ECRにプッシュします。
1. 作業ディレクトリの準備
mkdir -p adot-java-sample
cd adot-java-sample
2. 必要なファイルの作成
(前述の4ファイルを各々作成します)
一撃作成コマンド
# DiceApplication.javaの作成
cat > DiceApplication.java << 'EOL'
package otel;
import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DiceApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(DiceApplication.class);
app.setBannerMode(Banner.Mode.OFF);
app.run(args);
}
}
EOL
# RollController.javaの作成
cat > RollController.java << 'EOL'
package otel;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class RollController {
private static final Logger logger = LoggerFactory.getLogger(RollController.class);
@GetMapping("/rolldice")
public String index(@RequestParam("player") Optional<String> player) {
int result = this.getRandomNumber(1, 6);
if (player.isPresent()) {
logger.info("{} is rolling the dice: {}", player.get(), result);
} else {
logger.info("Anonymous player is rolling the dice: {}", result);
}
return Integer.toString(result);
}
@GetMapping("/health")
public String health() {
return "OK";
}
private int getRandomNumber(int min, int max) {
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
}
EOL
# build.gradle.ktsの作成
cat > build.gradle.kts << 'EOL'
plugins {
java
application
id("org.springframework.boot") version "3.1.3"
id("io.spring.dependency-management") version "1.1.3"
}
sourceSets {
main {
java.setSrcDirs(setOf("."))
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
}
EOL
# Dockerfileの作成
cat > Dockerfile << 'EOL'
FROM public.ecr.aws/docker/library/gradle:jdk17 AS builder
WORKDIR /adot-java-sample
COPY *.java .
COPY build.gradle.kts .
RUN ["gradle", "assemble"]
FROM gcr.io/distroless/java17-debian11:latest
WORKDIR /adot-java-sample
COPY --from=builder /adot-java-sample/build/libs/ ./build/libs/
ENV SERVER_PORT=80
ADD https://github.com/aws-observability/aws-otel-java-instrumentation/releases/download/v1.33.0/aws-opentelemetry-agent.jar ./aws-opentelemetry-agent.jar
ENV JAVA_TOOL_OPTIONS "-javaagent:/adot-java-sample/aws-opentelemetry-agent.jar"
ENV OTEL_RESOURCE_ATTRIBUTES "service.name=xraytestapp"
ENV OTEL_IMR_EXPORT_INTERVAL "60000"
ENV OTEL_EXPORTER_OTLP_ENDPOINT "http://127.0.0.1:4317"
ENV OTEL_METRICS_EXPORTER "none"
ENV OTEL_TRACES_EXPORTER="otlp"
EXPOSE 80
CMD ["/adot-java-sample/build/libs/adot-java-sample.jar"]
EOL
3. Dockerイメージのビルド
docker build -t adot-java-sample .
4. ECRリポジトリへの認証
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.ap-northeast-1.amazonaws.com
5. イメージのタグ付け
docker tag adot-java-sample:latest $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.ap-northeast-1.amazonaws.com/adot-java-sample:latest
ECR名は適宜更新してください。
6. イメージのプッシュ
docker push $(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.ap-northeast-1.amazonaws.com/adot-java-sample:latest
ECR名は適宜更新してください。
3.6 アクセスしてみてどのようにトレースできているか
ECSサービスをデプロイしてアプリケーションが起動したら、ALBを通じてエンドポイントにアクセスしてみましょう。
-
動作確認
-
/health
エンドポイントにアクセスして"OK"が返ってくることを確認 -
/rolldice
エンドポイントにアクセスして1〜6の数字が返ってくることを確認
-
-
X-Rayコンソールでトレースを確認
AWS X-Rayコンソールにアクセスすると、自動的に収集されたトレース情報を確認できます。
また、自動計装ではアプリケーションメトリクス(GCなど)も取得可能です。
さらに発展的な使い方として、「logback.xml」と「build.gradle.kts」を適切に設定すれば、ログにトレースIDを埋め込むことで、X-Ray上でログとトレースを紐づけることも可能です。
それについては次のブログにまとめることとします。
まとめ
AWS Distro for OpenTelemetry (ADOT)を活用することで、Javaアプリケーションのコードを一切変更せずに、AWS X-Rayを使った分散トレーシングを実現できることを確認しました。
この自動計装アプローチのメリットは:
- 開発チームの負担軽減: アプリケーションコードを変更する必要がない
- 標準化されたテレメトリ収集: OpenTelemetryという業界標準ベースの手法
- 可観測性の向上: トレースの取得可能となる
特にインフラチームとアプリケーションチームが分かれている組織では、この方法を採用することで導入の障壁を大きく下げることができます。マイクロサービスアーキテクチャやサーバーレスアプリケーションの監視においても、この自動計装のアプローチは大きな効果を発揮するでしょう。