AWS FargateでJavaアプリを自動計装してX-Rayでトレースする方法

# 1. はじめに

Spring BootなどのJavaアプリケーションをAWS Fargateで運用している環境で、分散トレーシングを実現するためにAWS X-Rayの導入を検討しているケースは多いのではないでしょうか。

従来のX-Ray導入方法では、アプリケーションコードの変更が必要でした。具体的には、AWS X-Ray SDKをアプリケーションに組み込み、トレースするコードを各所に追加するという作業が発生します。これはアプリケーション開発チームへの負担が大きく、既存システムへの導入ハードルが高いという課題がありました。

しかし、**AWS Distro for OpenTelemetry (ADOT)** を使用した**自動計装**という方法を使えば、アプリケーションコードを一切変更することなく、X-Rayによるトレーシングを実現できます。この記事では、その導入方法を具体的な手順とともに紹介します。

> 注意: 現時点で自動計装をサポートしているのはJavaとPythonのみです。
> 
> 参考： [AWS X-Ray のためのアプリケーションの計測](https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/xray-instrumenting-your-app.html)

# 2. AWS Distro for OpenTelemetry (ADOT)を使用したトレース取得のポイント

## 2.1 ADOTとは何か？

AWS Distro for OpenTelemetry (ADOT)は、オープンソースのObservabilityフレームワークである[OpenTelemetry](https://opentelemetry.io/)のAWS向けディストリビューションです。テレメトリデータ（トレース、メトリクス、ログ）の収集と転送を一貫した方法で行うことができます。

## 2.2 自動計装のメリット

1. **導入負荷の軽減**: アプリケーションコードを変更せずにテレメトリデータの取得ができる
2. **標準化**: OpenTelemetryという業界標準ベースの計装方法を採用

## 2.3 実装のための主要コンポーネント

ADOTを使ったJavaアプリケーションの自動計装を実現するためには、以下の4つの主要コンポーネントを正しく設定する必要があります。
今回はトレースに絞って記述します。

### 2.3.1 Dockerfile

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をサイドカーとして追加します。

```json
{
  "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への送信方法を定義します。

```yaml
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権限設定

適切な権限設定が必要です。

1. **タスクロール**：
   - `AWSXRayDaemonWriteAccess`マネージドポリシーを追加
   - ADOT CollectorがX-Rayにトレースデータを書き込むために必要

2. **タスク実行ロール**：
   - SSMパラメータストアへのアクセス権限を追加
   - 以下のようなIAMポリシーが必要
   ```json
   {
     "Version": "2012-10-17",
     "Statement": [
       {
         "Effect": "Allow",
         "Action": [
           "ssm:GetParameters"
         ],
         "Resource": "arn:aws:ssm:ap-northeast-1:*:parameter/otel-collector-config"
       }
     ]
   }
   ```

**ポイント**：
- タスクロールはタスク実行中に使用される権限
- タスク実行ロールはタスク起動時に使用される権限
- 最小権限の原則に基づいて必要な権限のみを付与

## 2.4 実装フロー

これらのコンポーネントを組み合わせた実装フローは以下のようになります。

1. DockerfileでADOTエージェントを含むコンテナイメージをビルド
2. SSMパラメータストアにOTEL Collector設定を保存
3. ECSタスク定義でアプリケーションコンテナとADOT Collectorサイドカーを定義
4. 必要なIAM権限を設定
5. ECSサービスをデプロイ

この実装により、アプリケーションコードを変更することなく、JVMレベルでの自動計装が実現され、トレースデータがX-Rayに送信されるようになります。

# 3. 実際にやってみた

## 3.1 AWS環境を準備する

必要なリソースは以下です。

- VPCとサブネット
- セキュリティグループ
- ALB（Application Load Balancer）
- ECSクラスター

カスタムドメインを活用する場合
- ACM
- Route53

![Xray検証構成図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/553930/426cb051-ce67-4551-a94f-720183b2442c.jpeg)

上記環境を構築するCloudFormationテンプレートを以下に用意しているので必要な方は活用してください。
(※Route53とACMは事前に用意してください。)

<details><summary>
Nwtwork.yaml
</summary>

```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]]
```

</details>

<details><summary>
ALB-ECS.yaml
</summary>

```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

```

</details>

すでに環境をお持ちの場合は、この手順をスキップしていただいて構いません。


## 3.2 SSMパラメータストアにOTEL Collector設定を保存
SSMパラメータストアに以下のコンフィグを`otel-collector-config`という名前で保存します。

```yaml
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サイドカーの追加**
```json
{
  "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の公式サイト](https://opentelemetry.io/docs/languages/java/getting-started/)で紹介されている「単純なサイコロAPIを提供するSpring Bootアプリケーション」です。

- `/rolldice` エンドポイント：1～6の乱数を返す
- `/health` エンドポイント：ヘルスチェック用

### 3.5.1 必要なファイルの準備

まず、以下の4つのファイルを用意します。

1\. **DiceApplication.java**（メインアプリケーション）
```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エンドポイント定義）
```java
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ビルド定義）
```kotlin
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**（多段ビルド＋自動計装設定）
```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**の重要なポイントは

1. **多段ビルド**: GradleでJavaアプリケーションをビルドした後、distrolessの軽量イメージを使用
2. **ADOTエージェント追加**: GitHubからAWS Distro for OpenTelemetry Javaエージェントをダウンロード
3. **Javaエージェント設定**: `-javaagent`オプションでADOTエージェントをJVMに読み込ませる
4. **OpenTelemetry設定**: 環境変数でサービス名やエンドポイントなどを指定

### 3.5.2 AWS CloudShellでのビルドとECRへのプッシュ

AWS CloudShellを使用してアプリケーションをビルドし、Amazon ECRにプッシュします。

1\. **作業ディレクトリの準備**
```bash
mkdir -p adot-java-sample
cd adot-java-sample
```

2\. **必要なファイルの作成**
(前述の4ファイルを各々作成します)
<details><summary>
一撃作成コマンド
</summary>

```bash
# 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
```
</details>

3\. **Dockerイメージのビルド**
```bash
docker build -t adot-java-sample .
```

4\. **ECRリポジトリへの認証**
```bash
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\. **イメージのタグ付け**
```bash
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\. **イメージのプッシュ**
```bash
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を通じてエンドポイントにアクセスしてみましょう。

1. **動作確認**
   - `/health`エンドポイントにアクセスして"OK"が返ってくることを確認
   - `/rolldice`エンドポイントにアクセスして1〜6の数字が返ってくることを確認

2. **X-Rayコンソールでトレースを確認**

AWS X-Rayコンソールにアクセスすると、自動的に収集されたトレース情報を確認できます。

![Xray検証.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/553930/b6c5d82b-c4ba-4c00-b29f-1792fd3ce7ad.jpeg)



また、自動計装ではアプリケーションメトリクス（GCなど）も取得可能です。
さらに発展的な使い方として、「logback.xml」と「build.gradle.kts」を適切に設定すれば、ログにトレースIDを埋め込むことで、X-Ray上でログとトレースを紐づけることも可能です。
それについては次のブログにまとめることとします。

# まとめ

AWS Distro for OpenTelemetry (ADOT)を活用することで、Javaアプリケーションのコードを一切変更せずに、AWS X-Rayを使った分散トレーシングを実現できることを確認しました。

この自動計装アプローチのメリットは:

1. **開発チームの負担軽減**: アプリケーションコードを変更する必要がない
3. **標準化されたテレメトリ収集**: OpenTelemetryという業界標準ベースの手法
4. **可観測性の向上**: トレースの取得可能となる

特にインフラチームとアプリケーションチームが分かれている組織では、この方法を採用することで導入の障壁を大きく下げることができます。マイクロサービスアーキテクチャやサーバーレスアプリケーションの監視においても、この自動計装のアプローチは大きな効果を発揮するでしょう。

# 参考資料

- [AWS Distro for OpenTelemetry ドキュメント](https://aws-otel.github.io/)
- [AWS X-Ray のためのアプリケーションの計測](https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/xray-instrumenting-your-app.html)
- [OpenTelemetry Java サンプルアプリケーション](https://opentelemetry.io/docs/languages/java/getting-started/)
- [AWS Distro for OpenTelemetry で APM に近付く](https://zenn.dev/ryoyoshii/articles/d016d71cb49564)
- [OpenTelemetryがサポートするライブラリとフレームワーク](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md#libraries--frameworks)