mazyu36の日記

某SIer所属のクラウドエンジニアのブログ

AWS CDKで各種ログに対するAmazon AthenaのPartition Projectionを実装する

Amazon AthenaにPartition Projection(パーティション射影)という機能があります。

dev.classmethod.jp

ざっくりいうとパーティション管理を自動化して、高速にクエリが実行でき、お財布にも優しいというものです。個人的にはめちゃくちゃ便利だなと思い、特にログの調査に活用しています。

ログ調査対象のサービスの内、大体どのプロジェクトでも使っているものがいくつかあります(ALB、VPCフローログ、CloudTrail....)。

これまではPartition Projectionの設定を行うCREATE文を毎回実行していたのですが、少し面倒なのでAWS CDKで実装し使いまわせるようにしました。

今回の実装の全体像は以下です。

1. 概要

対象のログ

今回Partition Projectionを実装する対象としたのは、以下のサービスのログです。

Partition Projectionsの設定をする際、対応するGlue Database/Tableを作成する形になります。ここではALBのアクセスログAWS WAFのトラフィックログは1つのDatabaseにするなど、ある程度グルーピングしています。

No Services Log Types Glue Database
1 Application Load Balancer Access Logs application_logs_database
2 AWS WAF Web Acl Traffic Logs application_logs_database
3 Amazon VPC VPC Flow Logs network_logs_database
4 Amazon Route53 Query Logs network_logs_database
5 AWS CloudTrail CloudTrail Logs security_logs_database
6 AWS Config History, Snapshot security_logs_database

実装方法

今回の実装は、以下の記事を参考にさせていただきました。

techblog.kiramex.com

Partition ProjectionとしてAWS::Glue::Tableを実装していく形になります。

2023/3現在、AWS GlueのL2 Constructは存在しないため、L1 Constructを使用する、もしくはAlpha版のモジュールを使う必要があります。Alpha版のモジュールの場合は個別にインストールが必要です。

今回は実装したものを今後使い回していきたいのが目的のため、安定しているL1 Constructを元に実装していきます(将来的に正式なL2 Constructが制定されたタイミングで移行を考えます)。

なおAlpha版でPartition Projectionを実装したい場合は以下が参考になります。

dev.classmethod.jp

2. 実装詳細

今回実装したものは以下に置いてあります。

github.com

プロジェクト構成

以下lib配下(StackとConstructの実装箇所)の構成を示します。例のごとくConstructをファイルごとに分割して実装し、構造化しています。

.
├── construct
│   ├── sample
│   │   ├── alb.ts
│   │   ├── athena.ts
│   │   ├── cloudTrail.ts
│   │   ├── config.ts
│   │   ├── route53.ts
│   │   ├── vpc.ts
│   │   └── waf.ts
│   ├── applicationLogsTable.ts
│   ├── networkLogsTable.ts
│   └── securityLogsTable.ts
├── appTableStack.ts
└── securityTableStack.ts
  • lib/construct/sample/配下はログ出力のためのサンプルを簡易実装しています。
    • 実装の大部分はBLEAをパクっています。
    • ALBはサーバーは存在せず固定値でステータスコード200を返却するよう実装しています。またドメイン設定はしていません。
    • 上記のためRoute53のクエリログは出力されません(適切にリソースを作成すれば、サンプルの実装で出力されることは確認済)。
    • athena.tsはワークグループとクエリログの出力先バケットを作成しています。
  • xxxxxLogsTable.tsは今回のメインです。Partition Projectionの設定(Glue Database/Table)を実装しています。詳細は後述します。
  • スタックは以下の2つに分けています。
    • appTablseStack.ts:application_logs_database(ALB, WAF)とnetwork_logs_database(VPC, Route53)をまとめています。1アカウント内に複数存在する可能性がリソースになります。
    • securityTableStack.ts:security_logs_database(CloudTrail, Config)をまとめています。これらは基本1アカウントに1つのため、上記から分離しています。

実装の流れ

以下lib/construct/applicationLogsTable.tsを元に、実装内容を簡単に説明します。

なお主要なログに対してPartition Projectionを設定するCREATE文は大体公式にあります(AWS WAFの場合)。こちらをCDKに落としてく形で進めると比較的楽に実装できます。

入力のインタフェース

バケット名や、Web ACL名などリソース依存の値はstringで受け取るようにしました。

ログの出力はCDK外で設定されていることも多いため、Partition ProjectionのみCDKで実装して、別の手段で作成したリソースの名称等をstringで渡して生成できるようにすることを意識しています。

export interface ApplicationLogsTableProps {
  albAccessLogsBucketName: string
  wafTrafficLogsBucketName: string
  webAclName: string
}

Glue データベースを作成

ここは特に難しいところはありません。カタログIDはアカウントIDを設定します。

    // Glue データベースを作成
    const applicationLogsDatabase = new glue.CfnDatabase(scope, 'ApplicationLogsDatabase', {
      catalogId: cdk.Stack.of(scope).account,
      databaseInput: {
        name: 'application_logs_database',
      },
    });

Glue テーブルを作成

メインの箇所になります。以下AWS WAFのログにおける定義例を示します。

    // Glue Table (WAF Traffic Logs) with Partition Projection
    const wafTrafficLogsTable = new glue.CfnTable(scope, "WafTrafficLogsTable", {
      databaseName: 'application_logs_database',
      catalogId: cdk.Stack.of(scope).account,
      tableInput: {
        name: 'waf_traffic_logs_tables',
        tableType: "EXTERNAL_TABLE",
        parameters: {
          "projection.enabled": true,
          "projection.date.type": "date",
          "projection.date.range": "NOW-1YEARS, NOW+9HOUR",
          "projection.date.format": "yyyy/MM/dd",
          "projection.date.interval": "1",
          "projection.date.interval.unit": "DAYS",
          "storage.location.template": `s3://${props.wafTrafficLogsBucketName}/AWSLogs/${cdk.Stack.of(scope).account}/WAFLogs/${cdk.Stack.of(this).region}/${props.webAclName}/` + "${date}",
        },
        storageDescriptor: {
          columns: [
            {
              "name": "timestamp",
              "type": "bigint"
            },
            {
              "name": "formatversion",
              "type": "int"
            },
            {
              "name": "webaclid",
              "type": "string"
            },
            {
              "name": "terminatingruleid",
              "type": "string"
            },
            {
              "name": "terminatingruletype",
              "type": "string"
            },
            {
              "name": "action",
              "type": "string"
            },
            {
              "name": "terminatingrulematchdetails",
              "type": "array<struct<conditiontype:string,location:string,matcheddata:array<string>>>"
            },
            {
              "name": "httpsourcename",
              "type": "string"
            },
            {
              "name": "httpsourceid",
              "type": "string"
            },
            {
              "name": "rulegrouplist",
              "type": "array<struct<rulegroupid:string,terminatingrule:struct<ruleid:string,action:string,rulematchdetails:string>,nonterminatingmatchingrules:array<struct<ruleid:string,action:string,rulematchdetails:array<struct<conditiontype:string,location:string,matcheddata:array<string>>>>>,excludedrules:array<struct<ruleid:string,exclusiontype:string>>>>"
            },
            {
              "name": "ratebasedrulelist",
              "type": "array<struct<ratebasedruleid:string,limitkey:string,maxrateallowed:int>>"
            },
            {
              "name": "nonterminatingmatchingrules",
              "type": "array<struct<ruleid:string,action:string>>"
            },
            {
              "name": "requestheadersinserted",
              "type": "string"
            },
            {
              "name": "responsecodesent",
              "type": "string"
            },
            {
              "name": "httprequest",
              "type": "struct<clientip:string,country:string,headers:array<struct<name:string,value:string>>,uri:string,args:string,httpversion:string,httpmethod:string,requestid:string>"
            },
            {
              "name": "labels",
              "type": "array<struct<name:string>>"
            },
            {
              "name": "captcharesponse",
              "type": "struct<responsecode:string,solvetimestamp:string,failureReason:string>"
            }
          ],
          inputFormat: "org.apache.hadoop.mapred.TextInputFormat",
          outputFormat: "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat",
          serdeInfo: {
            serializationLibrary: "org.openx.data.jsonserde.JsonSerDe",
            parameters: {
              'serialization.format': '1'
            }
          },
          location: `s3://${props.wafTrafficLogsBucketName}/AWSLogs/${cdk.Stack.of(scope).account}/WAFLogs/${cdk.Stack.of(this).region}/${props.webAclName}`,
        },
        partitionKeys: [
          {
            "name": "date",
            "type": "string"
          },
        ]
      }
    })

データベース名

データベース名は作成したcfnDatabaseの名称と一致させる必要があります。

      databaseName: 'application_logs_database',

Partition Projectionの設定

tableInputparametersで、 パーティションキーの設定を行なっていきます。

ここではdateパーティションキーとするよう設定しています。なお範囲については適当に広範囲にしてしまうと、大量のスキャンが走りパフォーマンスの悪化や、料金増大の恐れがあるので注意が必要です。

今回の実装では基本全て、過去1年分から現在(タイムゾーンを考慮して+9H)の範囲としています。

dev.classmethod.jp

またstorage.location.templateにおいてパスを指定していきます。バケット名やリージョン名はテンプレートリテラルを使用して埋め込んでいます。

ただし注意点としてパーティションキーは${key}の形式にする必要があり、そのままテンプレートリテラルに記載すると変数としてみなされるので、エスケープする or 例のように文字列として結合する等の対処が必要です。

      tableInput: {
        // 中略
        parameters: {
          "projection.enabled": true,
          "projection.date.type": "date",
          "projection.date.range": "NOW-1YEARS, NOW+9HOUR",
          "projection.date.format": "yyyy/MM/dd",
          "projection.date.interval": "1",
          "projection.date.interval.unit": "DAYS",
          "storage.location.template": `s3://${props.wafTrafficLogsBucketName}/AWSLogs/${cdk.Stack.of(scope).account}/WAFLogs/${cdk.Stack.of(this).region}/${props.webAclName}/` + "${date}",
        },

パーティションキーに関してはpartitionKeys配下にも記載します。ここのnameは上記の名称(projection.xxxx.yyyyyxxxx)と一致させる必要があります。

      partitionKeys: [
          {
            "name": "date",
            "type": "string"
          },
        ]

ALB, WAF, VPC, Route53は上記の通りdateパーティションキーとしていますが、CloudTrailとConfigは追加で設定をしています。

まず、CloudTrailはdateだけでなくregionパーティションキーとしています。enumでリージョンを定義しています。

        parameters: {
          "projection.enabled": "true",
          "projection.region.type": "enum",
          "projection.region.values": "us-east-1,us-east-2,us-west-1,us-west-2,af-south-1,ap-east-1,ap-south-1,ap-northeast-2,ap-southeast-1,ap-southeast-2,ap-northeast-1,ca-central-1,eu-central-1,eu-west-1,eu-west-2,eu-south-1,eu-west-3,eu-north-1,me-south-1,sa-east-1", // region
          "projection.date.type": "date",
          "projection.date.range": "NOW-1YEARS,NOW+9HOUR",
          "projection.date.format": "yyyy/MM/dd",
          "projection.date.interval": "1",
          "projection.date.interval.unit": "DAYS",
          "storage.location.template": `s3://${props.cloudTrailLogsBucketName}/AWSLogs/${cdk.Stack.of(scope).account}/` + "CloudTrail/${region}/${date}",
          "classification": "cloudtrail",
          "compressionType": "gzip",
          "typeOfData": "file",
        },

ConfigもCloudTrailと同様にregionパーティションキーに追加しています。

またdateパーティションキーとしていますが、yyyy/M/dMdになっている点が異なります。これはログの出力パスが数字一桁の場合は0埋めがされていないためです。

Config独自のパーティションキーとしてログの種類を示すtypeを追加しています。変更ログとスナップショットの2種類が存在するためです。

      tableInput: {
        name: 'config_logs',
        tableType: "EXTERNAL_TABLE",
        parameters: {
          "projection.enabled": "true",
          "projection.region.type": "enum",
          "projection.region.values": "us-east-1,us-east-2,us-west-1,us-west-2,af-south-1,ap-east-1,ap-south-1,ap-northeast-2,ap-southeast-1,ap-southeast-2,ap-northeast-1,ca-central-1,eu-central-1,eu-west-1,eu-west-2,eu-south-1,eu-west-3,eu-north-1,me-south-1,sa-east-1", // region
          "projection.date.type": "date",
          "projection.date.range": "NOW-1YEARS,NOW+9HOUR",
          "projection.date.format": "yyyy/M/d", // date
          "projection.date.interval": "1",
          "projection.date.interval.unit": "DAYS",
          "projection.type.type": "enum",
          "projection.type.values": "ConfigHistory,ConfigSnapshot", // type
          "storage.location.template": `s3://${props.configLogsBucketName}/AWSLogs/${cdk.Stack.of(scope).account}/Config/` + "${region}/${date}/${type}"
        },

Columnsの設定

ログの出力項目に合わせ、Columnを定義していきます。

arraystructを含む項目は、例のように1行かつ間のスペース無しで定義します。

        columns: [
            {
              "name": "timestamp",
              "type": "bigint"
            },
            // 中略
            {
              "name": "terminatingrulematchdetails",
              "type": "array<struct<conditiontype:string,location:string,matcheddata:array<string>>>"
            },
            //以下略

Glue DatabaseとTableの依存関係を定義

Glue Database, Tableを実装したのみの状態でデプロイすると、並列でデプロイしようとし失敗する可能性があります(Databaseを作成→Tableを作成となる必要あり)。

そのため依存関係を明示します。

    albAccessLogsTable.addDependency(applicationLogsDatabase)
    wafTrafficLogsTable.addDependency(applicationLogsDatabase)

3. 動作確認

CDKデプロイ後に、以下のようにパーティションキーを指定してAthenaからクエリを発行すれば対象のログを取得できます。

-- ALBアクセスログなど
SELECT
  *
FROM
  application_logs_database.alb_access_logs_table
WHERE
  date = '2023/03/01';

-- CludTrail
SELECT
  *
FROM
  security_logs_database.cloudtrail_logs_tables
WHERE
  region = 'ap-northeast-1' -- region
  and date = '2023/03/01';

-- Config
SELECT
  *
From
  security_logs_database.config_logs
WHERE
  region = 'ap-northeast-1'
  and date = '2023/3/1' -- yyyy/M/d
  and type = 'ConfigHistory' -- ログの種類
;

以下はApplication Load Balancer のアクセスログでの確認結果です。

おわりに

今後はCDKの実装を使いまわして楽にPartition Projectionの設定ができそうです。CloudFront等、別のサービスのログもそのうち追加で実装していこうと思います。