CloudwatchLogs を Kinesis Firehose 経由で S3 に保存する for CDK

# 構成図

![CloudWatchLogsToFirefoseToS3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280929/67538867-2f75-80c6-3b78-342520a148a4.png)


# やりたいこと

今回検証用のため、EC2 に Apache を起動し、
 access_log を S3 bucket-A に格納し、
 error_log を S3 bucket-B に格納
できるようする。

なお、S3 に格納するとき、CloudWatchLogs を S3 のプレフィックスにして保存する。

以降の説明では、A は access_log、B は error_log とする。

IAM ロールは CloudWatchLogs ごと、Firefose ごとに作成されるとリソース数が増えるため、今回は A と B それぞれ共通のものを利用する。


# CDK 構造図
 
```:tree
bin
└── CloudwatchLogsToFirehoseToS3.ts
lib
├── CloudwatchLogsToFirehoseToS3Construct.ts
├── CloudwatchLogsToFirehoseToS3Stack.ts
└── parameters.ts
```

`CloudwatchLogsToFirehoseToS3Construct.ts` で Construct として作成し、
`CloudwatchLogsToFirehoseToS3Stack` で 
　A と B
が作成されるようにしています。


# CDK コード

環境ごとにデプロイできるようしておく。

```bin/CloudwatchLogsToFirehoseToS3.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CloudwatchLogsToFirehoseToS3Stack } from '../lib/CloudwatchLogsToFirehoseToS3Stack';

const app = new cdk.App();
new CloudwatchLogsToFirehoseToS3Stack(app, 'Dev-CloudwatchLogsToFirehoseStack', {
  environmentName: 'dev',
  // env: { account: '123456789012', region: 'ap-northeast-1' }, 
});

new CloudwatchLogsToFirehoseToS3Stack(app, 'Stg-CloudwatchLogsToFirehoseStack', {
  environmentName: 'stg',
  // env: { account: '123456789012', region: 'ap-northeast-1' }, 
});

new CloudwatchLogsToFirehoseToS3Stack(app, 'Prod-CloudwatchLogsToFirehoseStack', {
  environmentName: 'prd',
  // env: { account: '123456789012', region: 'ap-northeast-1' }, 
});
```

A,B ごとに作成したいため、Construct を定義する。

```lib/CloudwatchLogsToFirehoseToS3Construct.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as firehose from 'aws-cdk-lib/aws-kinesisfirehose';
import * as logs from 'aws-cdk-lib/aws-logs';

interface CloudwatchLogsToFirehoseToS3ConstructProps {
  readonly environmentName: string;
  readonly productNames: string;
  readonly bucketName: string;
  readonly logGroupNames: string[];
  readonly iD: string;
}

export class CloudwatchLogsToFirehoseToS3Construct extends Construct {
  constructor(scope: Construct, id: string, props: CloudwatchLogsToFirehoseToS3ConstructProps) {
    super(scope, id);

    const { environmentName, productNames, bucketName, logGroupNames, iD } = props;

    // S3バケットを作成
    const s3Bucket = new s3.Bucket(this, `S3Bucket`, {
      bucketName,
      removalPolicy: cdk.RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE,
    });

    // Firefose用のエラーロググループを作成
    const firehoseErrorLogGroup = new logs.LogGroup(this, `FirehoseErrorLogGroup`, {
      logGroupName: `/aws/kinesisfirehose/${environmentName}-${productNames}-firehose-error-log-${iD}`,
      retention: logs.RetentionDays.ONE_MONTH,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // Firehose用のIAMロールを作成
    const firehoseRole = this.createFirehoseRole(environmentName, productNames, s3Bucket, iD, firehoseErrorLogGroup);

    // CloudWatch LogsからFirehoseへの送信を許可するIAMロールを作成
    const logsToFirehoseRole = this.createLogsToFirehoseRole(environmentName, productNames, iD);

    // CloudWatch LogsグループとFirehoseを作成
    this.createCloudWatchLogGroupsAndFirehose(environmentName, productNames, s3Bucket, logGroupNames, firehoseRole, logsToFirehoseRole, firehoseErrorLogGroup);
  }

  // Firehose用のIAMロールを作成するメソッド
  private createFirehoseRole(environmentName: string, productNames: string, bucket: s3.Bucket, iD: string, firehoseErrorLogGroup: logs.LogGroup): iam.Role {
    const firehoseRole = new iam.Role(this, `FirehoseRole-${iD}`, {
      roleName: `${environmentName}-${productNames}-firehose-role-${iD}`,
      assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com'),
    });

    firehoseRole.addToPolicy(new iam.PolicyStatement({
      actions: [
        's3:AbortMultipartUpload',
        's3:GetBucketLocation',
        's3:GetObject',
        's3:ListBucket',
        's3:ListBucketMultipartUploads',
        's3:PutObject'
      ],
      resources: [
        bucket.bucketArn,
        `${bucket.bucketArn}/*`
      ],
    }));

    firehoseRole.addToPolicy(new iam.PolicyStatement({
      actions: [
        'logs:PutLogEvents',
        'logs:CreateLogStream'
      ],
      resources: [
        firehoseErrorLogGroup.logGroupArn,
        `${firehoseErrorLogGroup.logGroupArn}:*`
      ],
    }));

    return firehoseRole;
  }

  // CloudWatch LogsからFirehoseへの送信を許可するIAMロールを作成するメソッド
  private createLogsToFirehoseRole(environmentName: string, productNames: string, iD: string): iam.Role {
    const logsToFirehoseRole = new iam.Role(this, `LogsToFirehoseRole-${iD}`, {
      roleName: `${environmentName}-${productNames}-logs-to-firehose-role-${iD}`,
      assumedBy: new iam.ServicePrincipal('logs.amazonaws.com'),
    });

    return logsToFirehoseRole;
  }

  // CloudWatch LogsとFirehoseを作成するメソッド
  private createCloudWatchLogGroupsAndFirehose(
    environmentName: string,
    productNames: string,
    bucket: s3.Bucket,
    logGroupNames: string[],
    firehoseRole: iam.Role,
    logsToFirehoseRole: iam.Role,
    firehoseErrorLogGroup: logs.LogGroup,
  ): void {
    // ロググループごとにサブスクリプションフィルターとFirehoseを作成
    for (const logGroupName of logGroupNames) {
      // ロググループ名の '/' を '-' に置き換える
      const sanitizedLogGroupName = logGroupName.replace(/\//g, '-');

      new logs.LogStream(this, `firehoseLogStream-${sanitizedLogGroupName}`, {
        logGroup: firehoseErrorLogGroup,
        logStreamName:sanitizedLogGroupName,
      });

      // Firehoseのデリバリーストリームを作成
      const firehoseStream = new firehose.CfnDeliveryStream(this, `FirehoseStream-${sanitizedLogGroupName}`, {
        deliveryStreamName: `${environmentName}-${productNames}-firehose-stream-${sanitizedLogGroupName}`,
        deliveryStreamType: 'DirectPut',
        extendedS3DestinationConfiguration: {
          bucketArn: bucket.bucketArn,
          prefix: `${logGroupName}/`,
          errorOutputPrefix: `${logGroupName}/error/`,
          roleArn: firehoseRole.roleArn,
          bufferingHints: { intervalInSeconds: 300, sizeInMBs: 5 },
          cloudWatchLoggingOptions: {
            enabled: true,
            logGroupName: firehoseErrorLogGroup.logGroupName,
            logStreamName: `${sanitizedLogGroupName}`,
          },
        },
      });

      // CloudWatch LogsのIAMロールにポリシーを追加
      logsToFirehoseRole.addToPolicy(new iam.PolicyStatement({
        actions: ['firehose:PutRecord'],
        resources: [firehoseStream.attrArn],
      }));

      // ロググループを作成
      const logGroup = new logs.LogGroup(this, `LogGroup-${sanitizedLogGroupName}`, {
          logGroupName,
          retention: logs.RetentionDays.ONE_MONTH,
          removalPolicy: cdk.RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE,
        });

      // サブスクリプションフィルターを作成してログをFirehoseに送信
      const subscriptionFilter = new logs.CfnSubscriptionFilter(this, `SubscriptionFilter-${sanitizedLogGroupName}`, {
        filterName: `${environmentName}-${productNames}-subscription-filter-${sanitizedLogGroupName}`,
        logGroupName: logGroup.logGroupName,
        filterPattern: '',
        destinationArn: firehoseStream.attrArn,
        roleArn: logsToFirehoseRole.roleArn,
      });

      subscriptionFilter.node.addDependency(firehoseStream);
      subscriptionFilter.node.addDependency(logsToFirehoseRole);
    }
  }
}
```

A、B のスタックを作成。

```lib/CloudwatchLogsToFirehoseToS3Stack.ts
import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import { CloudwatchLogsToFirehoseToS3Construct } from './CloudwatchLogsToFirehoseToS3Construct';
import * as parameters from './parameters';

interface CloudwatchLogsToFirehoseToS3StackProps extends StackProps {
  readonly environmentName: string;
}

export class CloudwatchLogsToFirehoseToS3Stack extends Stack {
  constructor(scope: cdk.App, id: string, props: CloudwatchLogsToFirehoseToS3StackProps) {
    super(scope, id, props);

    const environmentName = props.environmentName;
    const productNames = parameters.productNames;

    // Aのスタックを作成
    new CloudwatchLogsToFirehoseToS3Construct(this, 'CloudwatchLogsToFirehoseToS3ConstructA', {
      environmentName,
      productNames,
      bucketName: `${environmentName}-${productNames}-s3bucket-a`,
      logGroupNames: parameters.logGroupNamesA,
      iD: 'A',
    });

    // Bのスタックを作成
    new CloudwatchLogsToFirehoseToS3Construct(this, 'CloudwatchLogsToFirehoseToS3ConstructB', {
      environmentName,
      productNames,
      bucketName: `${environmentName}-${productNames}-s3bucket-b`,
      logGroupNames: parameters.logGroupNamesB,
      iD: 'B',
    });
  }
}
```

ロググループをパラメータにリストとして定義。

```lib/parameters.ts
export const productNames = "<プロダクト名>";

export const logGroupNamesA = [
  "access_log/A001",
  "access_log/A002",
];

export const logGroupNamesB = [
  "error_log/B001",
  "error_log/B002",
];
```

# CDK デプロイ

開発環境にのみデプロイする。

```
cdk deploy Dev-CloudwatchLogsToFirehoseStack
```

# CloudWatch エージェント設定ファイル

001 の EC2インスタンスの CloudWatch エージェント設定ファイルは以下の通りとします。

```001 opt/aws/amazon-cloudwatch-agent/bin/config.json
{
        "agent": {
                "run_as_user": "root"
        },
        "logs": {
                "logs_collected": {
                        "files": {
                                "collect_list": [
                                        {
                                                "file_path": "/var/log/httpd/access_log",
                                                "log_group_class": "STANDARD",
                                                "log_group_name": "access_log/A001",
                                                "log_stream_name": "{instance_id}",
                                                "retention_in_days": 30
                                        },
                                        {
                                                "file_path": "/var/log/httpd/error_log",
                                                "log_group_class": "STANDARD",
                                                "log_group_name": "error_log/B001",
                                                "log_stream_name": "{instance_id}",
                                                "retention_in_days": 30
                                        }
                                ]
                        }
                }
        }
}
```

002 の EC2インスタンスの CloudWatch エージェント設定ファイルは以下の通りとします。
（今回は、検証のため当方では、002 の EC2インスタンスは作成しませんでした。）

```002 opt/aws/amazon-cloudwatch-agent/bin/config.json
{
        "agent": {
                "run_as_user": "root"
        },
        "logs": {
                "logs_collected": {
                        "files": {
                                "collect_list": [
                                        {
                                                "file_path": "/var/log/httpd/access_log",
                                                "log_group_class": "STANDARD",
                                                "log_group_name": "access_log/A002",
                                                "log_stream_name": "{instance_id}",
                                                "retention_in_days": 30
                                        },
                                        {
                                                "file_path": "/var/log/httpd/error_log",
                                                "log_group_class": "STANDARD",
                                                "log_group_name": "error_log/B002",
                                                "log_stream_name": "{instance_id}",
                                                "retention_in_days": 30
                                        }
                                ]
                        }
                }
        }
}
```

# 確認

CDK をデプロイした時点で、S3 にテストデータが格納されたことを確認できます。

![スクリーンショット 2024-08-16 12.52.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280929/716c9963-2600-dce5-770f-6d8f7aa70b1a.png)

念の為、Apache にリクエストを実行し、
`s3://dev-XXX-s3bucket-a/access_log/A001/YYYY/MM/DD/hh/`
に access_log が S3 へ格納されたことを確認します。

![スクリーンショット 2024-08-16 13.03.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280929/9c4b6f8b-f4ac-2a17-05c6-914f9f44f1b6.png)

同じく、Apache で意図的にエラーを引き起こし、
`s3://dev-XXX-s3bucket-b/error_log/B001/YYYY/MM/DD/hh/`
に error_log が S3 へ格納されたことを確認します。

![スクリーンショット 2024-08-16 13.49.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280929/3a954680-a2fa-76ac-3337-7a631cdb8de9.png)