[アップデート]Amazon CloudWatch Application SignalsがGAしました!CDKでサンプル作ってみた

[アップデート]Amazon CloudWatch Application SignalsがGAしました!CDKでサンプル作ってみた

Amazon CloudWatch Application SignalsがGAしました!Application Signalsを試しに使うための環境をCDKで作り、実際にSLO/SLIを設定してみます。
Clock Icon2024.06.30

はじめに

先日AWS上でSLO/SLI設定を比較的簡単に実現できるAmazon CloudWatch Application SignalsがGAしました!GA前からAWS上ではAPMがどう実現可能なのか、何が違うのかという記事はいくつかありました。

本稿ではApplication Signalsの簡単な概要と提供環境/言語/リージョンについての紹介に合わせて、実際にサンプルを作ってどんな動作をするのか確認してみます。実際に0から構築することで何が必要なのか、どんなことが出来るようになるのかを紹介します。

CDKなどの事前セットアップ作業が多いので、Applications Signalsの純粋な挙動が気になる方は「Application Signals 使ってみた」の章まで飛ばして読んでください。

元アップデート記事

参考元ドキュメント

Amazon CloudWatch Application Signalsの概要

Application Signals自体は端的に言うとService Level Indicator (SLI)/Service Level Objective (SLO)の計測をAWS上で出来るものです。以下の画像のように特定のエンドポイントに対して、SLOを設定してリクエストが何%正常だったか、SLO/SLIに対してアラートを設定して通知を受けたり出来ます。

上記ではルートのGETに対してのみSLOを設定していますが、任意のエンドポイントに対してSLOを設定することが出来ます。

対象となるアーキテクチャ

現状は以下の3つです。

  • Amazon EKS
  • Amazon ECS
  • Amazon EC2

現状はLambdaやApp Runnerなどのアーキテクチャはサポートされていません。今回のサンプルではECSで試してみます。ECSに関しては、EC2/Fargateどちらの上でも計測することが可能です。おそらくEKS Fargate構成も問題ないはずです。

サポートする言語

  • Java (JVM バージョン 8、11、および 17)
  • Python(バージョン 3.8 以降)

上記のJava/Pythonがサポートされています。また言語やフレームワークごとに制限事項があるので詳細は以下のページを確認してください。

サポートされるリージョン

GA時点では、AWS カナダ西部 (カルガリー):ca-west-1以外の商用リージョンすべてでサポートされています。

Application Signalsを使う前の準備

今回は以下のアーキテクチャで実際に使ってみます。

設定手順は以下のドキュメントにまとまっています。今回はこちらの手順を参考にCDKでコード化します。ステップごとにどんな設定がいるのかコードから抜粋して紹介します。

サンプルコードは以下に公開しています。Step2~4までのステップを1個ずつコミットしているので、コミット履歴を見てもらえるとステップ感での違いを確認できます。

Step0: 事前準備

手順にはないインフラ部分の準備を行います。ECS on Fargate構成を作成するため、コンテナイメージを格納するECRのリポジトリ、VPCやNAT Gateway or Instanceなどいくつかネットワーク周りの設定も必要になります。今回は以下のCDKのコードで必要なインフラ構成を事前準備します。

まずはECRの設定を記載します。今回はサンプルなので、リポジトリ名は固定でタグのイミュータビリティはオフにしています。

import * as cdk from "aws-cdk-lib";
import { aws_ecr as ecr } from "aws-cdk-lib";
import type { Construct } from "constructs";

export class EcrStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id);

    // --- ECR ---

    // Create a repository
    const repository = new ecr.Repository(this, "apm-sample", {
      repositoryName: "apm-test",
      imageTagMutability: ecr.TagMutability.MUTABLE, // not recommended
    });
    repository.addLifecycleRule({ maxImageCount: 3 });
  }
}

次にコンテナ用のDockerfileやアプリコードを設定します。今回は非常に簡易な内容で確認するためFast APIを使ったサンプルAPIをほぼそのまま使います。ファイルの構成は以下のようになりますが、詳細はGitHubのコードを参考にしてください。

app
├── __init__.py
├── main.py
└── requirements.txt

Pythonのコードはほぼ以下の参考元のままで構築します。

from typing import Union

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"Hello": "World"}

@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
    return {"item_id": item_id, "q": q}

@app.get("/health-check")
def read_health_check():
    return {"request": "OK"}

ステップの最後にVPC周りのネットワーク設定やECSの設定を追加します。節約のためNAT Instanceを使う構成にしていますが、NAT Gatewayを使ったり、権限を絞るためにVPC Endpointを使用しても特に問題ないです。

コードが長いので以下の図の設定で雰囲気だけでもご確認ください。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as ecr from "aws-cdk-lib/aws-ecr";

export class ApplicationSignalsSampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const natProvider = ec2.NatProvider.instanceV2({
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T4G,
        ec2.InstanceSize.NANO,
      ),
      machineImage: ec2.MachineImage.latestAmazonLinux2023({
        cpuType: ec2.AmazonLinuxCpuType.ARM_64,
      }),
      defaultAllowedTraffic: ec2.NatTrafficDirection.OUTBOUND_ONLY,
    });

    const myVpc = new ec2.Vpc(this, `${id}-Vpc`, {
      ipAddresses: ec2.IpAddresses.cidr("10.100.0.0/16"),
      maxAzs: 2,
      natGateways: 1,
      natGatewayProvider: natProvider,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "Public",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "Protected",
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });
    natProvider.securityGroup.addIngressRule(
      ec2.Peer.ipv4(myVpc.vpcCidrBlock),
      ec2.Port.allTraffic(),
    );

    const securityGroupForAlb = new ec2.SecurityGroup(this, `${id}-SgAlb`, {
      vpc: myVpc,
      allowAllOutbound: false,
    });
    securityGroupForAlb.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));
    securityGroupForAlb.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.allTcp());
    const securityGroupForFargate = new ec2.SecurityGroup(
      this,
      `${id}-SgFargate`,
      {
        vpc: myVpc,
        allowAllOutbound: false,
      },
    );
    securityGroupForFargate.addIngressRule(
      securityGroupForAlb,
      ec2.Port.tcp(80),
    );
    securityGroupForFargate.addEgressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.allTcp(),
    );

    // ALB for App Server
    const albForApp = new elbv2.ApplicationLoadBalancer(this, `${id}-Alb`, {
      vpc: myVpc,
      internetFacing: true,
      securityGroup: securityGroupForAlb,
      vpcSubnets: myVpc.selectSubnets({
        subnetGroupName: "Public",
      }),
    });

    // ECS

    // Roles
    const executionRole = new iam.Role(this, `${id}-EcsTaskExecutionRole`, {
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonECSTaskExecutionRolePolicy",
        ),
      ],
    });
    const serviceTaskRole = new iam.Role(this, `${id}-EcsServiceTaskRole`, {
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMFullAccess"),
      ],
    });

    // --- Fargate Cluster ---
    // ECS Task
    const serviceTaskDefinition = new ecs.FargateTaskDefinition(
      this,
      `${id}-ServiceTaskDefinition`,
      {
        cpu: 256,
        memoryLimitMiB: 512,
        executionRole: executionRole,
        taskRole: serviceTaskRole,
      },
    );

    const logGroup = new logs.LogGroup(this, `${id}-ServiceLogGroup`, {
      retention: logs.RetentionDays.THREE_MONTHS,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    serviceTaskDefinition
      .addContainer(`${id}-ServiceTaskContainerDefinition`, {
        image: ecs.ContainerImage.fromEcrRepository(
          ecr.Repository.fromRepositoryName(
            this,
            `${id}-RepositoryName`,
            "apm-test",
          ),
          "latest", // not recommended
        ),
        logging: ecs.LogDriver.awsLogs({
          streamPrefix: "ApmSample",
          logGroup,
        }),
      })
      .addPortMappings({
        containerPort: 80,
        hostPort: 80,
        protocol: ecs.Protocol.TCP,
      });

    // Cluster
    const cluster = new ecs.Cluster(this, `${id}-Cluster`, {
      vpc: myVpc,
      containerInsights: true,
    });

    const fargateService = new ecs.FargateService(
      this,
      `${id}-FargateService`,
      {
        cluster,
        vpcSubnets: myVpc.selectSubnets({ subnetGroupName: "Protected" }),
        securityGroups: [securityGroupForFargate],
        taskDefinition: serviceTaskDefinition,
        desiredCount: 1,
        maxHealthyPercent: 200,
        minHealthyPercent: 50,
        enableExecuteCommand: true,
        circuitBreaker: {
          enable: true,
        },
      },
    );

    const albListener = albForApp.addListener(`${id}-AlbListener`, {
      port: 80,
    });
    
    const fromAppTargetGroup = albListener.addTargets(
      `${id}-FromAppTargetGroup`,
      {
        port: 80,
        targets: [fargateService],
        healthCheck: {
          enabled: true,
          path: "/health-check",
          healthyHttpCodes: "200", // See: /app/main.py
        },
      },
    );
  }
}

Step1: Application Signalsを有効化

最初にCloudWatchの画面でApplication Signals有効にする必要があります。以下のように画面から有効化するとAWSServiceRoleForCloudWatchApplicationSignalsロールが作成されます。

また画面上にもステップ2とあるのですが、実際に選択するとECSの場合カスタムに当たり、画面上からは特に設定作業などは不要なことがわかります。

Step2: ECS Task Roleに権限を追加

ECSがCloudWatchやX-Rayなどにデータ送信できるようにタスクロールへCloudWatchAgentServerPolicyポリシーを追加します。CDK上だと以下の部分に追記になります。

const serviceTaskRole = new iam.Role(this, `${id}-EcsServiceTaskRole`, {
  assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMFullAccess"),
    iam.ManagedPolicy.fromAwsManagedPolicyName(  // ←追加
      "CloudWatchAgentServerPolicy",
    ),
  ],
});

Step3: CloudWatch Agent の設定ファイルを作成

CloudWatch Agentのサイドカーで利用するための設定ファイルをSSM Parameter Storeに保存します。

{
  "traces": {
    "traces_collected": {
      "application_signals": {}
    }
  },
  "logs": {
    "metrics_collected": {
      "application_signals": {}
    }
  }
}
% aws ssm put-parameter \
    --name "ecs-cwagent" \
    --type "String" \
    --value "`cat ./app/ecs-cwagent.json`" \
    --region "ap-northeast-1"

Step4: CloudWatch Agent と AWS Distro for OpenTelemetry(ADOT) のコンテナ

設定としては最後にCloudWatch AgentのサイドカーとAWS Distro for OpenTelemetry(ADOT)のサイドカーを追加します。またタスク内で使用するVolumeやアプリのコンテナでADOT用に設定する環境変数するなどを追加します。

4.1 Volumeの追加

...
// ボリュームの作成
serviceTaskDefinition.addVolume({
    name: "opentelemetry-auto-instrumentation-python",
});
...

4.2 CloudWatch エージェント サイドカーを作成

タスク定義に追加するコンテナの設定をCDKで書くと以下のようになります。SSM Parameter Storeからの取得はCDK部分で書いて先程のJSONを取得しています。

...
// --- ecs-cwagent ---
serviceTaskDefinition.addContainer(`${id}-CwAgentContainerDefinition`, {
  image: ecs.ContainerImage.fromRegistry(
    // See: https://gallery.ecr.aws/cloudwatch-agent/cloudwatch-agent
    "public.ecr.aws/cloudwatch-agent/cloudwatch-agent:latest-amd64",
  ),
  secrets: {
    CW_CONFIG_CONTENT: ecs.Secret.fromSsmParameter(
      ssm.StringParameter.fromStringParameterName(
        this,
        "CWConfigParameter",
        "ecs-cwagent",
      ),
    ),
  },
  logging: ecs.LogDrivers.awsLogs({
    streamPrefix: "ecs",
    logGroup: new cdk.aws_logs.LogGroup(this, "LogGroup", {
      logGroupName: "/ecs/ecs-cwagent",
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    }),
  }),
});
...

4.3 ADOTのサイドカーを設定

ADOTの公式ECRリポジトリからイメージを取得します。ADOTとアプリの動くコンテナではストレージを共有する必要があるので、addMountPointsで追加しています。

...
// --- init ---
serviceTaskDefinition
  .addContainer("InitContainer", {
    image: ecs.ContainerImage.fromRegistry(
      // See: https://gallery.ecr.aws/aws-observability/adot-autoinstrumentation-python
      "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.2.0",
    ),
    essential: false,
    command: [
      "cp",
      "-a",
      "/autoinstrumentation/.",
      "/otel-auto-instrumentation-python",
    ],
  })
  .addMountPoints({
    sourceVolume: "opentelemetry-auto-instrumentation-python",
    containerPath: "/otel-auto-instrumentation-python",
    readOnly: false,
  });
...

4.4 アプリのコンテナにADOT用の環境変数を設定

OpenTelemetry(OTEL)で使用するための環境変数をアプリが動いているコンテナに追加します。またStep 4.1で追加したVolumeもここでマウントします。またOTEL_TRACES_SAMPLEROTEL_TRACES_SAMPLERの部分は以下のOpenTelemetryのページを参考に修正しているのでご確認ください。

...
    // ボリュームの作成
    serviceTaskDefinition.addVolume({
      name: "opentelemetry-auto-instrumentation-python",
    });

    const mainContainer = serviceTaskDefinition.addContainer(
      `${id}-ServiceTaskContainerDefinition`,
      {
        image: ecs.ContainerImage.fromEcrRepository(
          ecr.Repository.fromRepositoryName(
            this,
            `${id}-RepositoryName`,
            "apm-test",
          ),
          "latest", // not recommended
        ),
        logging: ecs.LogDriver.awsLogs({
          streamPrefix: "ApmSample",
          logGroup,
        }),
        environment: {
          // Example: https://github.com/aws-observability/application-signals-demo/blob/main/pet_clinic_insurance_service/ec2-setup.sh
          OTEL_RESOURCE_ATTRIBUTES: `service.name=APM_SAMPLE,aws.log.group.names=${logGroup.logGroupName}`,
          OTEL_AWS_APPLICATION_SIGNALS_ENABLED: "true",
          OTEL_METRICS_EXPORTER: "none",
          OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
          // If sending metrics to the CloudWatch sidecar, configure: http://127.0.0.1:4316/v1/metrics
          OTEL_AWS_APPLICATION_SIGNALS_EXPORTER_ENDPOINT:
            "http://127.0.0.1:4316/v1/metrics",
          // If sending traces to the CloudWatch sidecar, configure: http://localhost:4316/v1/traces
          OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://localhost:4316/v1/traces",
          // See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-Configure.html
          OTEL_TRACES_SAMPLER: "parentbased_traceidratio",
          OTEL_TRACES_SAMPLER_ARG: "0.05",
          // OTEL_PROPAGATORS: "",
          OTEL_PYTHON_DISTRO: "aws_distro",
          OTEL_PYTHON_CONFIGURATOR: "aws_configurator", // docsはaws_configuration
          PYTHONPATH:
            "/otel-auto-instrumentation-python/opentelemetry/instrumentation/auto_instrumentation:/code/app:/otel-auto-instrumentation-python",
          // DJANGO_SETTINGS_MODULE: "", // Not Django
        },
      },
    );
    mainContainer.addPortMappings({
      containerPort: 80,
      hostPort: 80,
      protocol: ecs.Protocol.TCP,
    });
    mainContainer.addMountPoints({
      sourceVolume: "opentelemetry-auto-instrumentation-python",
      containerPath: "/otel-auto-instrumentation-python",
      readOnly: false,
    });
...

Step5: アプリをデプロイ

事前準備はすべて整ったので後はコードをデプロイします。まずは先にECSでイメージを使うためにECRをデプロイします。

% npx cdk deploy EcrStack --require-approval never

次にECRに今回作成したアプリをビルドしてイメージを作成し、ECRにプッシュします。

% export IMAGE_TAG=latest
% export REPOSITORY_NAME=apm-test
% export AWS_ACCOUNT_ID=123456789012
% export REGISTRY_NAME=$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com
% docker build --platform=linux/x86_64 -t $IMAGE_TAG .

% docker tag $IMAGE_TAG $REGISTRY_NAME/$REPOSITORY_NAME

# require assume-role or other
% aws ecr get-login-password --region ap-northeast-1 | \
    docker login --username AWS --password-stdin $REGISTRY_NAME
% docker push "$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/$REPOSITORY_NAME:$IMAGE_TAG"

最後にVPCやECS周りのインフラをデプロイします。

% npx cdk deploy ApplicationSignalsSampleStack --require-approval never

上記ですべてのリソースがデプロイされApllication Signalsでアプリが観測できるようになります。この後は実際にApplication Signalsの画面から動きを確認してみます。

Application Signals 使ってみた

ここからはセッティングしたサンプルアプリを使って、Application Signalsの動きを確認してみます。データがCloudWatchなどに向けて転送され始めると、以下のように画面が変化します。

画面をスクロールすると、サービスの部分にAPM_SAMPLEが出ます。これはアプリのコンテナのOTEL_RESOURCE_ATTRIBUTES環境変数の中のservice.nameに設定したものが表示されます。こちらの画面の「SLOを作成」押下するとSLOの作成画面に移ります。

SLOを設定

SLOの作成画面では、SLOに名前をつけてSLIとしてどのURLのエンドポイントの状態を観測するかを設定できます。また観測する単位やしきい値だけでなく、画面右側に直近3時間でこのSLIを設定した場合アラートが発生するかも確認できます。実際に運用を続けている際に、メトリクスの様子をプレビューしながら設定できるので便利そうです。

画面下にスクロールすると、SLOの設定画面に移ります。集計の間隔と達成目標、警告する際のしきい値をそれぞれ設定できます。設定した場合、どういうメトリクスと判定基準になるのかが文章でも表示され、同じ用に画面右側にも設定した場合、直近3時間のデータでしきい値に対してどうなるかが確認できます。

SLI/SLO違反が発生した際のアラートも設定できます。具体的には以下の画面のように、SLI状態アラーム、SLO達成目標アラーム、SLO警告アラームが設定できます。それぞれのしきい値を超過した場合、SNSに対してパブリッシュでき、メールやChatbotを組み合わせればSlackやTeams、Chimeにも連携できます。

上記が終われば、SLOの設定は完了です。

メトリクスの状態確認

Application Signalsの該当サービスを選択し、サービスオペレーションの画面を開くと設定したURLエンドポイントの状態がわかります。今回は/のGETメソッドを設定したので、こちらに対して99,90,50パーセンタイルに対するレイテンシーやリクエスト数、障害、エラー発生、可用性が確認出来ます。

また上記の画面には、実際にアプリへリクエストが来たURLエンドポイントがすべて表示されるので、画面からURLエンドポイントを選択して先程のようにSLOを設定することも可能です。

所感

これでAWS単体でURLエンドポイント単位のSLO/SLIを簡単に設定できます!コンテナの設定さえ理解してしまえば設定できるのでとても良さそうです。後は対応言語が今のところ、Java/Pythonしかないので個人的にはNode.js/Goの対応が待ち遠しいです。そうなれば多くのアプリケーションでSLO/SLIを設定する際の選択肢になるのではと思います。もし気になった方は、公式のサンプルやこちらのCDKコードで是非試して使ってみてください!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.