CloudWatch 運用調査機能のチケット発行システム機能をためしてみた

CloudWatch 運用調査機能のチケット発行システム機能をためしてみた

Clock Icon2025.04.30

こんにちは。たかやまです。

先日、CloudWatch自動調査機能の東京リージョンのプレビューが提供されました。

https://dev.classmethod.jp/articles/q-developer-operational-investigations-tokyo/

その折、CloudWatch自動調査機能ではサードパーティのチケット発行システムと連携されている機能が提供されていることを思い出しました。

私自身最近Jira Service Managementを触っており、ちょうど試す機会があったので今回はその連携方法について紹介します。

さきにまとめ

  • チケット発行システムではJira CloudとService Nowをサポート
  • 東京リージョンはチケット発行システム統合は未提供
  • チケット起票は新規作成と既存チケットへの紐づけが可能で、ステータスの自動更新機能は未提供

やってみる

チケット発行システム統合

せっかくなので東京リージョンで設定しようと思いましたが、残念ながら東京リージョンでのチケット発行システム統合は未提供です。

This feature is available only in the US East (N. Virginia) Region.

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Investigations-Integrations.html

なのでバージニアリージョンでJira Cloud向けに使ってみます。

「サードパーティーの統合」 > 「チケット発行システム統合」からJiraサービスを統合します。

  • 名前: 統合の識別名(例: Jira)
  • インスタンスタイプ: Jiraのタイプを選択
  • ユーザー名: Jiraへのログインに使用するユーザー名
  • APIトークン: JiraのAPIアクセス用トークン
  • Jiraサイト名: Jiraのサイトアドレス(例: example.atlassian.netのexampleの部分)
  • プロジェクトキー: チケットを作成する先のJiraプロジェクトのキー

CleanShot 2025-04-29 at 13.08.26@2x 2.png

ステータスが「設定済み」になっていればOKです。

CleanShot 2025-04-29 at 19.06.09.png

ただし、この時点で接続確認までとれているとは限らないようで、後続のJiraチケットの紐づけがうまくいかない場合には、環境設定を管理しているSecrets Managerの内容を確認してみてください。

CleanShot 2025-04-30 at 18.43.12@2x.png

CleanShot 2025-04-29 at 19.12.24.png

Jiraにチケット起票する

障害を起こすために、CDKでWeb3層アーキテクチャのCDKコードを用意します。このコードは、ALB、EC2、RDSを使った一般的なWeb3層アーキテクチャを定義しています。

CDKコード
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as rds from 'aws-cdk-lib/aws-rds';
import { Construct } from 'constructs';

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

    const vpc = this.createVpc();
    const { albSg, webSg, dbSg } = this.createSecurityGroups(vpc);
    const webServerRole = this.createEmptyIamRole();
    const webServers = this.createEc2Instances(vpc, webSg, webServerRole);
    const alb = this.createAlb(vpc, albSg, webServers);
    const mysql = this.createMySqlInstance(vpc, dbSg);
    this.defineOutputs(alb);
  }

  /**
   * VPCとサブネットを作成します
   */
  private createVpc(): ec2.Vpc {
    const vpc = new ec2.Vpc(this, 'WebAppVpc', {
      maxAzs: 2,
      natGateways: 2,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'Public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: 'Application',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
        {
          cidrMask: 24,
          name: 'Database',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });

    const cfnVpc = vpc.node.defaultChild as ec2.CfnVPC;
    cfnVpc.tags.setTag('Name', 'sample-vpc');

    const subnets = vpc.publicSubnets.concat(vpc.privateSubnets).concat(vpc.isolatedSubnets);
    subnets.forEach((subnet, index) => {
      const cfnSubnet = subnet.node.defaultChild as ec2.CfnSubnet;
      let subnetType = 'unknown';

      if (vpc.publicSubnets.includes(subnet)) {
        subnetType = 'public';
      } else if (vpc.isolatedSubnets.includes(subnet)) {
        subnetType = 'isolated';
      } else if (vpc.privateSubnets.includes(subnet)) {
        subnetType = 'private';
      }

      cfnSubnet.tags.setTag('Name', `sample-${subnetType}-subnet-${index + 1}`);
    });

    this.createSsmEndpoints(vpc);

    return vpc;
  }

  /**
   * SSM接続用のVPCエンドポイントを作成します
   */
  private createSsmEndpoints(vpc: ec2.Vpc): void {
    new ec2.InterfaceVpcEndpoint(this, 'SsmEndpoint', {
      vpc,
      service: ec2.InterfaceVpcEndpointAwsService.SSM,
      subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      privateDnsEnabled: true,
    });

    new ec2.InterfaceVpcEndpoint(this, 'SsmMessagesEndpoint', {
      vpc,
      service: ec2.InterfaceVpcEndpointAwsService.SSM_MESSAGES,
      subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      privateDnsEnabled: true,
    });

    new ec2.InterfaceVpcEndpoint(this, 'Ec2MessagesEndpoint', {
      vpc,
      service: ec2.InterfaceVpcEndpointAwsService.EC2_MESSAGES,
      subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      privateDnsEnabled: true,
    });
  }

  /**
   * セキュリティグループを作成します
   */
  private createSecurityGroups(vpc: ec2.Vpc): {
    albSg: ec2.SecurityGroup;
    webSg: ec2.SecurityGroup;
    dbSg: ec2.SecurityGroup;
  } {
    const albSg = new ec2.SecurityGroup(this, 'AlbSecurityGroup', {
      vpc,
      description: 'Security group for ALB',
      allowAllOutbound: true,
    });
    (albSg.node.defaultChild as ec2.CfnSecurityGroup).tags.setTag('Name', 'sample-alb-sg');
    albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP from anywhere');

    const webSg = new ec2.SecurityGroup(this, 'WebSecurityGroup', {
      vpc,
      description: 'Security group for Web servers',
      allowAllOutbound: true,
    });
    (webSg.node.defaultChild as ec2.CfnSecurityGroup).tags.setTag('Name', 'sample-web-sg');
    webSg.addIngressRule(albSg, ec2.Port.tcp(80), 'Allow HTTP from ALB');

    const dbSg = new ec2.SecurityGroup(this, 'DbSecurityGroup', {
      vpc,
      description: 'Security group for RDS',
      allowAllOutbound: false,
    });
    (dbSg.node.defaultChild as ec2.CfnSecurityGroup).tags.setTag('Name', 'sample-db-sg');
    dbSg.addIngressRule(webSg, ec2.Port.tcp(3306), 'Allow MySQL from Web servers');

    return {
      albSg,
      webSg,
      dbSg,
    };
  }

  /**
   * EC2インスタンス用のIAMロールを作成します(SSM接続用の権限付き)
   */
  private createEmptyIamRole(): iam.Role {
    const role = new iam.Role(this, 'WebServerRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      description: 'Role for Web Servers with SSM access',
      roleName: 'sample-ec2-role',
    });

    role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'));

    return role;
  }

  /**
   * EC2インスタンスを作成します
   */
  private createEc2Instances(
    vpc: ec2.Vpc,
    securityGroup: ec2.SecurityGroup,
    role: iam.Role
  ): ec2.Instance[] {
    const amznLinux2023 = ec2.MachineImage.latestAmazonLinux2023({
      cpuType: ec2.AmazonLinuxCpuType.X86_64,
    });

    const instances: ec2.Instance[] = [];
    const subnetSelection = vpc.selectSubnets({
      subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
    });

    for (let i = 0; i < Math.min(2, subnetSelection.subnets.length); i++) {
      const userData = ec2.UserData.forLinux();
      userData.addCommands(
        'yum update -y',
        'yum install -y httpd',
        'systemctl start httpd',
        'systemctl enable httpd',
        `echo "<html><body><h1>インスタンス${i + 1}</h1></body></html>" > /var/www/html/index.html`
      );

      const instance = new ec2.Instance(this, `WebServer${i + 1}`, {
        vpc,
        vpcSubnets: {
          subnets: [subnetSelection.subnets[i]],
        },
        instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
        machineImage: amznLinux2023,
        securityGroup,
        role,
        userData,
      });

      const cfnInstance = instance.node.defaultChild as ec2.CfnInstance;
      cfnInstance.tags.setTag('Name', `sample-web-server-${i + 1}`);

      instances.push(instance);
    }

    return instances;
  }

  /**
   * ALBを作成します
   */
  private createAlb(
    vpc: ec2.Vpc,
    securityGroup: ec2.SecurityGroup,
    instances: ec2.Instance[]
  ): elbv2.ApplicationLoadBalancer {
    const alb = new elbv2.ApplicationLoadBalancer(this, 'WebAppALB', {
      vpc,
      internetFacing: true,
      securityGroup,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PUBLIC,
      },
      loadBalancerName: 'sample-alb',
    });

    const targetGroup = new elbv2.ApplicationTargetGroup(this, 'WebServerTargetGroup', {
      vpc,
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.INSTANCE,
      healthCheck: {
        path: '/',
        interval: cdk.Duration.seconds(30),
        timeout: cdk.Duration.seconds(5),
      },
      targetGroupName: 'sample-tg',
    });

    instances.forEach((instance) => {
      targetGroup.addTarget(new targets.InstanceTarget(instance));
    });

    alb.addListener('HttpListener', {
      port: 80,
      open: true,
      defaultTargetGroups: [targetGroup],
    });

    instances.forEach((instance, index) => {
      new cdk.CfnOutput(this, `WebServerInstance${index + 1}Id`, {
        value: instance.instanceId,
        description: `EC2 Instance ${index + 1} ID`,
        exportName: `WebServerInstance${index + 1}Id`,
      });
    });

    new cdk.CfnOutput(this, 'TargetGroupArn', {
      value: targetGroup.targetGroupArn,
      description: 'Target Group ARN',
      exportName: 'WebServerTargetGroupArn',
    });

    return alb;
  }

  /**
   * RDS for MySQLインスタンスをMultiAZ構成で作成します
   */
  private createMySqlInstance(
    vpc: ec2.Vpc,
    securityGroup: ec2.SecurityGroup
  ): rds.DatabaseInstance {
    const instance = new rds.DatabaseInstance(this, 'WebAppDatabase', {
      engine: rds.DatabaseInstanceEngine.mysql({
        version: rds.MysqlEngineVersion.VER_8_0_33,
      }),
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO),
      multiAz: true,
      vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
      securityGroups: [securityGroup],
      databaseName: 'webappdb',
      credentials: rds.Credentials.fromGeneratedSecret('admin'),
      allocatedStorage: 20,
      storageType: rds.StorageType.GP2,
      backupRetention: cdk.Duration.days(0),
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      deletionProtection: false,
      deleteAutomatedBackups: true,
      instanceIdentifier: 'sample-mysql',
      parameterGroup: rds.ParameterGroup.fromParameterGroupName(
        this,
        'MySqlParameterGroup',
        'default.mysql8.0'
      ),
    });

    new cdk.CfnOutput(this, 'DatabaseEndpoint', {
      value: instance.dbInstanceEndpointAddress,
      description: 'The endpoint of the MySQL database',
      exportName: 'MySqlEndpoint',
    });

    return instance;
  }

  /**
   * CloudFormation出力を定義します
   */
  private defineOutputs(alb: elbv2.ApplicationLoadBalancer): void {
    new cdk.CfnOutput(this, 'AlbDnsName', {
      value: alb.loadBalancerDnsName,
      description: 'The DNS name of the application load balancer',
      exportName: 'WebAppAlbDnsName',
    });
  }
}

const app = new cdk.App();
new Web3TierStack(app, 'SampleStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  description: 'Web 3-tier architecture environment',
}); 

EC2を停止して5XXエラーを起こします。

エラーを起こしてCloudWatch メトリクスから 「Investigate」 > 「新しい調査を開始」を選択します。

CleanShot 2025-04-29 at 21.12.25@2x.png

調査結果はCloudWatch AIオペレーションの「調査」から確認することができます。

こちらの調査結果を確認すると「リンクされたJiraチケットはありません」という表示が追加されていることがわかります。

CleanShot 2025-04-29 at 21.17.09@2x.png

「チケットの作成」を試してみます。

以下のエラーが出力されました。

有効な課題タイプを指定してください

CleanShot 2025-04-30 at 09.58.22@2x.png

利用している環境がカスタマイズしたJira Service Managementの環境のため、通常のJiraのようなエピック、ストーリー、タスク、サブタスク、バグの標準的な課題タイプを用意していないためエラーが発生しているかもしれません。

では、もう一つの「チケットをアタッチ」で既存のチケットに紐づけてみます。

チケットIDは「プロジェクトキー-タスク番号」の形式で指定します。

CleanShot 2025-04-30 at 13.25.32@2x 2 2.png

こちらは問題なく紐づけることができ、「リンクされたJiraチケット」の箇所に「Jira チケットを表示」というリンクが追加されています。

CleanShot 2025-04-30 at 11.56.24.png

実際に紐づけ先のチケットを確認すると調査結果が表示されていることが確認できます。

CleanShot 2025-04-30 at 11.33.10@2x.png

また、Amazon Qからの提案を承諾し、フィードに追加されると自動でチケット側のフィード内容にも反映されるような動きになっています。

CleanShot 2025-04-30 at 18.58.10@2x.png
CleanShot 2025-04-30 at 19.01.07@2x.png

メトリクスの埋込部分はさすがに表現できていないですね
CleanShot 2025-04-30 at 19.03.38@2x.png
CleanShot 2025-04-30 at 19.03.57@2x.png

また、調査をアーカイブした場合にJira側のステータスを自動更新するような動きも現時点ではないようです。
アーカイブに伴うチケットのステータス変更はこれからの更新に期待したいですね。

CleanShot 2025-04-30 at 19.06.38@2x.png

最後に

今回はCloudWatchの自動調査機能とJira Service Managementの連携方法を紹介しました。

CloudWatchの自動調査機能からシームレスにチケットを発行できるのはいいですね。

ステータス更新や東京リージョンでのチケット発行システム統合の提供はこれからのアップデートに期待したいです!

この記事が誰かのお役に立てれば幸いです。

以上、たかやま(@nyan_kotaroo)でした。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.