MNTSQ Techブログ

リーガルテック・カンパニー「MNTSQ(モンテスキュー)」のTechブログです。

Route 53 による DNS クエリログを全部 Athena で取り扱えるようにした

MNTSQ Tech Blog TOP > 記事一覧 > Route 53 による DNS クエリログを全部 Athena で取り扱えるようにした

はじめに

MNTSQ はそのサービスの性質(「契約」の集約、一元管理、活用)上、セキュリティの維持と向上が至上命題です。 セキュリティへの取り組みには幾つかのアプローチがありますが、何が不足しているのか、どういった対処が必要かという点を突き止めるには情報が必要です。これはどういったアプローチを取るにしても共通して重要な観点と思います。

本稿はこの情報の獲得のためのログ収集範囲の拡充を行った記録となります。対象は Route 53 の DNS クエリログです。

なぜ DNS クエリログを取るか

DNS クエリログはその名前の通り DNS へのクエリのログです。つまり

  • いつ
  • 誰が
  • 何を
  • どこから(「誰が」と同一の情報になる場合あり)

DNS クエリ単位で得られます。Route 53 で得られる DNS クエリログには以下2種類があります。

つまり Route 53 においては上述 DNS クエリログを

  1. インターネット → Route 53 公開 hosted zone(公開 DNS クエリログ)
  2. VPC → インターネット(リゾルバクエリログ)

の2方向に関して収集することができます。これによって得られる情報はいくつか考えられますが、

  1. インターネット → Route 53 公開 hosted zone
    • 所謂 attack surface を狙われている形跡の確認
    • 意図しないホストに対してのリクエストが継続していないか等、接続エラーの確認
  2. VPC → インターネット
    • VPC 内から意図しない通信が発生していないかの確認

といったものがパッと思い付くだけでも挙げられます。実際にログを取ってみて初めて気付ける活用法もあるはずなので、まずはログを取ることを目的としてもよいでしょう。

DNS クエリログ収集構成

構成図を以下に示します。

AWSACC1 = Route 53 リソース稼動アカウント(図では1つだが実際には複数存在)
Analysis = ログ分析用アカウント(1つのみ存在)

Route 53 が生成する各 DNS クエリログを最終的には専用の AWS アカウント内に用意した S3 バケットに集約し、当該アカウントの Athena からログを解析する構成となります。

ログを必ずしも専用の AWS アカウントに集約する必要は無いのですが、今回は Athena でのログ検索時の利便性の面から、ログ集約先および活用場所をひとつの場所に絞るようにしました。S3 上にログを集約する取り組みが DNS クエリログについては初であった点も保存先選定の柔軟さに一役買っています。

図から判るとおり、DNS クエリログによって S3 への保存方法が異なります。

ゾルバクエリログはログ出力先を複数選べ、選択肢の中には S3 がデフォルトで存在します*1。 一方で公開 DNS クエリログについては CloudWatch Logs 以外にログを出力する選択肢はありません*2。また CloudWatch Logs ロググループは us-east-1 にあるものだけが利用可 という制約もあります。

弊社でログ検索用に整備している Athena とその関連リソースは ap-northeast-1 にあることを前提にしているので、ここは出来れば ap-northeast-1 に寄せたいところです。このあたりを踏まえて公開 DNS クエリログについては

  1. Data Firehose を使い us-east-1 内で CloudWatch Logs から S3 へログを移設
  2. S3 レプリケーションで us-east-1 から ap-northeast-1 へリージョンを跨いで最終目的地となる S3 バケットへログを保存

という構成をとるようにしました。

Terraform コード

Route 53 ログを生成する側を submitter、ログを最終的に保管し Athena で検索する側を receiver とし、2つのコードを例示します。 前述の構成図でいえば AWSACC1 に相当するものが submitter、Analysis に相当するものが receiver になります。 いずれも実際に使っているコードを改変しての例示となります。

submitter

以下を実施するコードです。Route 53 各ゾーン (private / public) および VPC は既に存在するものとします。

main.tf

data "aws_caller_identity" "current" {}

# リゾルバクエリログ収集用コード
resource "aws_route53_resolver_query_log_config" "main" {
  name            = var.route53["resolver_query_log"]["config_name"]
  destination_arn = var.route53["resolver_query_log"]["bucket_arn"]
}

resource "aws_route53_resolver_query_log_config_association" "main" {
  resolver_query_log_config_id = aws_route53_resolver_query_log_config.main.id
  resource_id                  = var.vpc["id"]
}

# 公開 DNS クエリログ収集用コード
resource "aws_cloudwatch_log_group" "aws_route53_public" {
  provider = aws.us-east-1

  name              = var.route53["public_dns_query_log"]["log_group_name"]
  retention_in_days = 14 # S3 上のログを実運用上は使うので CloudWatch Logs には長期保管する必要がない
}

data "aws_iam_policy_document" "route53_query_logging" {
  statement {
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = [
      aws_cloudwatch_log_group.aws_route53_public.arn,
    ]

    principals {
      identifiers = ["route53.amazonaws.com"]
      type        = "Service"
    }
  }
}

resource "aws_cloudwatch_log_resource_policy" "route53_public_query_logging_policy" {
  provider = aws.us-east-1

  policy_document = data.aws_iam_policy_document.route53_query_logging.json
  policy_name     = "${var.route53["resolver_query_log"]["keyword"]}-policy"
}

resource "aws_route53_query_log" "public" {
  provider = aws.us-east-1
  depends_on = [
    aws_cloudwatch_log_resource_policy.route53_public_query_logging_policy
  ]

  cloudwatch_log_group_arn = aws_cloudwatch_log_group.aws_route53_public.arn
  zone_id                  = aws_route53_zone.public.zone_id
}

# us-east-1 内で CloudWatch Logs から S3 へログを Data Firehose 経由で移す
resource "aws_cloudwatch_log_subscription_filter" "s3_stream_filter" {
  provider = aws.us-east-1

  name           = "${var.route53["public_dns_query_log"]["keyword"]}-to-firehose"
  log_group_name = aws_cloudwatch_log_group.aws_route53_public.name

  # 全ログを転送対象にしたいので filter_pattern は空にする
  filter_pattern = ""

  destination_arn = aws_kinesis_firehose_delivery_stream.aws_route53_public.arn
  role_arn        = aws_iam_role.route53_public_query_logs_to_firehose_role.arn
}

resource "aws_cloudwatch_log_group" "route53_public_firehose_log" {
  provider = aws.us-east-1

  name              = "/aws/kinesisfirehose/${aws_kinesis_firehose_delivery_stream.main.name}"
  retention_in_days = 14 # 最終保存先 S3 バケットにレプリケートされたログを実運用上では使うので長期間の保持は不要
}

resource "aws_kinesis_firehose_delivery_stream" "main" {
  provider = aws.us-east-1

  name        = var.route53["public_dns_query_log"]["keyword"]
  destination = "extended_s3"

  extended_s3_configuration {
    role_arn   = aws_iam_role.route53_public_query_logging_role.arn
    bucket_arn = aws_s3_bucket.aws_route53_public.arn

    buffering_size = 64 # MB 単位。dynamic partitioning が有効の場合必須

    /*
      Data Firehose 内で Route 53 公開 DNS ログを Athena が解釈できる形式に変換する。詳細は後述
      実施している内容は以下のとおり
        - CloudWatch Logs から Data Firehose に流れてくるログは gzip 圧縮されているのでこれを展開
        - 展開した内容は1行に複数の JSON オブジェクトが含まれる形式になっているので jq を使い1行1オブジェクトになるよう展開
        - S3 レプリケーションの事情で Data Firehose から S3 へログデータを置く場合は適当な prefix が欲しいので、これを "logs/" とできるよう設定
     */
    dynamic_partitioning_configuration {
      enabled = "true"
    }

    processing_configuration {
      enabled = "true"
      processors {
        type = "MetadataExtraction"
        parameters {
          parameter_name  = "JsonParsingEngine"
          parameter_value = "JQ-1.6"
        }
        parameters {
          parameter_name  = "MetadataExtractionQuery"
          parameter_value = "{prefix: {dummy: (\"logs\")} | .dummy}" # 実質的に "logs" という固定文字列を返すだけ
        }
      }
      processors {
        type = "Decompression"
        parameters {
          parameter_name  = "CompressionFormat"
          parameter_value = "GZIP"
        }
      }
      processors {
        type = "AppendDelimiterToRecord"
      }
    }

    prefix              = "!{partitionKeyFromQuery:prefix}/${data.aws_caller_identity.current.account_id}/!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/!{timestamp:HH}/"
    error_output_prefix = "error/${data.aws_caller_identity.current.account_id}/!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/!{timestamp:HH}/!{firehose:error-output-type}/"
    compression_format  = "GZIP"

    cloudwatch_logging_options {
      enabled         = true
      log_group_name  = aws_cloudwatch_log_group.route53_public_firehose_log.name
      log_stream_name = "S3Delivery"
    }
  }
}

resource "aws_s3_bucket" "aws_route53_public" {
  provider = aws.us-east-1

  bucket = var.route53["public_dns_query_log"]["source_bucket_name"]
}

resource "aws_s3_bucket_lifecycle_configuration" "aws_route53_public" {
  provider = aws.us-east-1

  bucket = aws_s3_bucket.aws_route53_public.id
  rule {
    status = "Enabled"
    id     = "delete after 180 days"
    expiration {
      days = 180 # 集約先 S3 バケット側のログを使うので然程長期間保持しておく必要はない
    }
    filter {
      prefix = ""
    }
  }
}

resource "aws_s3_bucket_versioning" "aws_route53_public" {
  provider = aws.us-east-1

  bucket = aws_s3_bucket.aws_route53_public.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "aws_route53_public" {
  provider = aws.us-east-1

  bucket                  = aws_s3_bucket.aws_route53_public.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "aws_route53_public" {
  provider = aws.us-east-1

  bucket = aws_s3_bucket.aws_route53_public.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

data "aws_iam_policy_document" "aws_route53_public_bucket_policy" {
  provider = aws.us-east-1

  statement {
    effect    = "Allow"
    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.aws_route53_public.arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["logging.s3.amazonaws.com"]
    }

    condition {
      test     = "ArnLike"
      variable = "aws:SourceArn"
      values = [
        "arn:aws:s3:::mntsq-${var.env}-*"
      ]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
  }
}

resource "aws_s3_bucket_policy" "aws_route53_public" {
  provider = aws.us-east-1

  bucket = aws_s3_bucket.aws_route53_public.bucket
  policy = data.aws_iam_policy_document.aws_route53_public_bucket_policy.json
}

resource "aws_s3_bucket_replication_configuration" "route53_public_query_logging" {
  provider = aws.us-east-1

  depends_on = [aws_s3_bucket_versioning.aws_route53_public]

  bucket = aws_s3_bucket.aws_route53_public.id
  role   = aws_iam_role.route53_public_query_logging_replication.arn

  rule {
    id     = "route53-public-dns-query-log-replication"
    status = "Enabled"

    filter {
      prefix = "logs"
    }

    delete_marker_replication {
      status = "Disabled"
    }

    destination {
      account       = var.route53["public_dns_query_log"]["destination_account_id"]
      bucket        = var.route53["resolver_query_log"]["destination_bucket_arn"]
      storage_class = "STANDARD_IA"

      access_control_translation {
        owner = "Destination"
      }
    }
  }
}

provider.tf

provider "aws" {
  region = "ap-northeast-1"
}

provider "aws" {
  alias  = "us-east-1"
  region = "us-east-1"
}

terraform {
  required_version = "~> 1.11.4"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0.0"
    }
  }
}

receiver

submitter が生成した Route 53 ログを最終的に保管する S3 バケットを管理します。こちらはリージョンを跨がず ap-northeast-1 のみで完結するので、provider.tf の例示は省略します

main.tf

/*
  DNS クエリログを収集する対象となる AWS アカウントは AWS Organizations で管理している
  これらアカウントに対してのアクセス許可(S3 バケットポリシ)を個々設定するのは手間なので、organization 単位で許可できるようにする
  これには organization ID が要り、その値を得るための data
  */
data "aws_organizations_organization" "main" {}

# リゾルバクエリログの最終保管場所となる S3 バケットとその周辺のリソースを定義
resource "aws_s3_bucket" "route53_resolver_query_logs" {
  bucket = var.s3["resolver_query_logs"]["name"]
}

resource "aws_s3_bucket_server_side_encryption_configuration" "route53_resolver_query_logs" {
  bucket = aws_s3_bucket.route53_resolver_query_logs.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "route53_resolver_query_logs" {
  bucket = aws_s3_bucket.route53_resolver_query_logs.id
  rule {
    id = "transition to archives"
    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }
    transition {
      days          = 60
      storage_class = "GLACIER"
    }
    filter {
      prefix = ""
    }
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "route53_resolver_query_logs" {
  bucket = aws_s3_bucket.route53_resolver_query_logs.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

data "aws_iam_policy_document" "route53_resolver_query_logs" {
  statement {
    effect  = "Allow"
    actions = ["s3:GetBucketAcl"]

    resources = [
      aws_s3_bucket.route53_resolver_query_logs.arn,
    ]

    principals {
      type        = "Service"
      identifiers = ["delivery.logs.amazonaws.com"]
    }
  }

  statement {
    effect  = "Allow"
    actions = ["s3:PutObject"]

    resources = [
      "${aws_s3_bucket.route53_resolver_query_logs.arn}/*",
    ]

    principals {
      type        = "Service"
      identifiers = ["delivery.logs.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values = [
        "bucket-owner-full-control",
      ]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:PrincipalOrgID"
      values   = [data.aws_organizations_organization.main.id]
    }
  }
}

resource "aws_s3_bucket_policy" "route53_resolver_query_logs" {
  bucket = aws_s3_bucket.route53_resolver_query_logs.id
  policy = data.aws_iam_policy_document.route53_resolver_query_logs.json
}

# 公開 DNS クエリログの最終保管場所となる S3 バケットとその周辺のリソースを定義
resource "aws_s3_bucket" "route53_public_dns_query_logging" {
  bucket = var.s3["public_dns_query_logs"]["name"]
}

resource "aws_s3_bucket_versioning" "route53_public_dns_query_logging" {
  bucket = aws_s3_bucket.route53_public_dns_query_logging.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "route53_public_dns_query_logging" {
  bucket = aws_s3_bucket.route53_public_dns_query_logging.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_ownership_controls" "route53_public_dns_query_logging" {
  bucket = aws_s3_bucket.route53_public_dns_query_logging.id

  rule {
    object_ownership = "BucketOwnerPreferred"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "route53_public_dns_query_logging" {
  bucket = aws_s3_bucket.route53_public_dns_query_logging.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

data "aws_iam_policy_document" "route53_public_dns_query_logging" {
  statement {
    effect = "Allow"
    actions = [
      "s3:ReplicateObject",
      "s3:ReplicateDelete",
      "s3:ReplicateTags",
      "s3:GetObjectVersionTagging",
      "s3:ObjectOwnerOverrideToBucketOwner",
    ]
    resources = ["${aws_s3_bucket.route53_public_dns_query_logging.arn}/*"]

    principals {
      type        = "AWS"
      identifiers = ["*"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:PrincipalOrgID"
      values   = [data.aws_organizations_organization.main.id]
    }
  }

  statement {
    effect = "Allow"
    actions = [
      "s3:GetBucketVersioning",
      "s3:PutBucketVersioning",
      "s3:ListBucket",
      "s3:GetReplicationConfiguration",
    ]
    resources = [aws_s3_bucket.route53_public_dns_query_logging.arn]

    principals {
      type        = "AWS"
      identifiers = ["*"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:PrincipalOrgID"
      values   = [data.aws_organizations_organization.main.id]
    }
  }

  statement {
    effect = "Allow"

    actions = ["s3:PutObject"]
    resources = [
      "${aws_s3_bucket.route53_public_dns_query_logging.arn}/*",
    ]

    principals {
      type        = "Service"
      identifiers = ["logging.s3.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values   = ["bucket-owner-full-control"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:PrincipalOrgID"
      values   = [data.aws_organizations_organization.main.id]
    }
  }
}

resource "aws_s3_bucket_policy" "route53_public_dns_query_logging" {
  bucket = aws_s3_bucket.route53_public_dns_query_logging.id
  policy = data.aws_iam_policy_document.route53_public_dns_query_logging.json
}

公開 DNS クエリログの取り扱いについての注意

上記サンプルコード内で Data Firehose を使い CloudWatch Logs から S3 へ公開 DNS クエリログを送出する過程で、何やら小難しいことをしている箇所に目が付くと思います。

  extended_s3_configuration {
    role_arn   = aws_iam_role.route53_public_query_logging_role.arn
    bucket_arn = aws_s3_bucket.aws_route53_public.arn

    buffering_size = 64 # MB 単位。dynamic partitioning が有効の場合必須

    /*
      Data Firehose 内で Route 53 公開 DNS ログを Athena が解釈できる形式に変換する。詳細は後述
      実施している内容は以下のとおり
        - CloudWatch Logs から Data Firehose に流れてくるログは gzip 圧縮されているのでこれを展開
        - 展開した内容は1行に複数の JSON オブジェクトが含まれる形式になっているので jq を使い1行1オブジェクトになるよう展開
        - S3 レプリケーションの事情で Data Firehose から S3 へログデータを置く場合は適当な prefix が欲しいので、これを "logs/" とできるよう設定
     */
    dynamic_partitioning_configuration {
      enabled = "true"
    }

    processing_configuration {
      enabled = "true"
      processors {
        type = "MetadataExtraction"
        parameters {
          parameter_name  = "JsonParsingEngine"
          parameter_value = "JQ-1.6"
        }
        parameters {
          parameter_name  = "MetadataExtractionQuery"
          parameter_value = "{prefix: {dummy: (\"logs\")} | .dummy}" # 実質的に "logs" という固定文字列を返すだけ
        }
      }
      processors {
        type = "Decompression"
        parameters {
          parameter_name  = "CompressionFormat"
          parameter_value = "GZIP"
        }
      }
      processors {
        type = "AppendDelimiterToRecord"
      }
    }

これは Athena でログを処理することを前提とした前処理を Data Firehose のみで(= ログ処理用の Lambda 関数等を噛ませないで)実施する為の措置です。

通常 CloudWatch Logs にある Route 53 公開 DNS クエリログを Data Firehose でシンプルに S3 へ送出すると以下のような改行なしで複数の JSON オブジェクトが1行に集約されたものが得られます(実際のログを適当にマスクし例示します)。

{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/YVR52-R2","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522674731119363142938582278340608","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP YVR52-R2 192.0.2.143 -"}]}{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522685262133562904066894168588288","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 -"}]}{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SFO9-SN1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522690947843897953596035415605248","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com AAAA NOERROR UDP SFO9-SN1 192.0.2.144 -"}]}{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SEA900-R3","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522692678857209236868848315727872","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP SEA900-R3 2001:DB8::143 -"}]}{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522703737195603523266279501332480","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 -"}]}{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/ATL58-R1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047077085475805385546231824257833115761174552619646976","timestamp":1750931493000,"message":"1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP ATL58-R1 192.0.2.148 192.0.2.0/24"}]}{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/NRT8-SO1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047077085475805385546231840537028970579482687526338560","timestamp":1750931493000,"message":"1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP NRT8-SO1 192.0.2.10 -"}]}{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/KUL51-R1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047082905970302202038872078382990508713243113432088576","timestamp":1750931754000,"message":"1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24"}]}

ところがこの形式の JSON ログは Athena では受け付けられません。Athena は1行1エントリの JSON ログを要求するためです*3。つまり上記例でいえば

{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/YVR52-R2","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522674731119363142938582278340608","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP YVR52-R2 192.0.2.143 -"}]}
{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522685262133562904066894168588288","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 -"}]}
{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SFO9-SN1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522690947843897953596035415605248","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com AAAA NOERROR UDP SFO9-SN1 192.0.2.144 -"}]}
{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SEA900-R3","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522692678857209236868848315727872","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP SEA900-R3 2001:DB8::143 -"}]}
{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047076594859411017872522703737195603523266279501332480","timestamp":1750931471000,"message":"1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 -"}]}
{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/ATL58-R1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047077085475805385546231824257833115761174552619646976","timestamp":1750931493000,"message":"1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP ATL58-R1 192.0.2.148 192.0.2.0/24"}]}
{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/NRT8-SO1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047077085475805385546231840537028970579482687526338560","timestamp":1750931493000,"message":"1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP NRT8-SO1 192.0.2.10 -"}]}
{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/KUL51-R1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047082905970302202038872078382990508713243113432088576","timestamp":1750931754000,"message":"1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24"}]}

のような JSON ログとなっている必要があります。よって Data Firehose から S3 へログを流す際にその中身を書き換える必要が出てきます。Data Firehose ではデータ処理の過程で Lambda 関数にその役目を担わせることができるので*4それを使うのも手ですが、お世話が必要な主体をあまり増やしたくありません。

同じような事例が無いか調査していたところ

medium.com

が基本的な構想として多いに参考になり、また S3 によるレプリケーションを考える場合の prefix 付与においては

dev.classmethod.jp

が大変参考になりました。つまりはコード中のコメントにもあるとおり

  • CloudWatch Logs から Data Firehose に流れてくるログは gzip 圧縮されているのでこれを展開
  • 展開した内容は1行に複数の JSON オブジェクトが含まれる形式になっているので jq を使い1行1オブジェクトになるよう展開
  • S3 レプリケーションの事情で Data Firehose から S3 へログデータを置く場合は適当な prefix が欲しいので、これを "logs/" とできるよう設定

を Data Firehose のみで実施することが出来、これは dynamic partitioning*5 によって達成が可能ということになります。

本来 dynamic partitioning はログに含まれるキー値から S3 へオブジェクトを保存する際の prefix を決定し Athena をはじめとする S3 をデータソースとする解析系サービス向けにパーティションを整備するための機能ですが、弊社のケースではそこまで凝ったことは不要で、JSON ログをその構造を維持しつつログエントリ単位で適当に改行したいという共有が満たせれば OK です。先に示したコードも processing_configuration ブロックが割合シンプルなものになっています。

このコードは前述2記事に拠るところが多大に有ります。この場を借りて感謝申し挙げます。

ログを Athena で検索する

さて前項までに Route 53 由来の DNS クエリログを S3 に集約して保存できるようになりました。これを Athena で検索してゆくようにする手筈を整えます。

ゾルバクエリログについては Use partition projection - Amazon Athena で示される内容が充分実用に耐えるものになりますが、公開 DNS クエリログについては AWS としての公式サポートが CloudWatch Logs であるということを踏まえても想像に難くなく、このようなクエリ例が存在しません。従って自前で頑張る必要があります。

早い話以下のような内容が使えます。前述の Data Firehose コードによって S3 へログが送出される前提の内容です。例示値や置換すべき値は前述のリゾルバクエリ用の例に倣っています。

CREATE EXTERNAL TABLE r53_public_dns_logs (
    messageType string,
    owner string,
    logGroup string,
    logStream string,
    subscriptionFilters array<
      string
    >,
    logEvents array<
      struct<
        id: string,
        timestamp: bigint,
        message: string
      >
    >
)
PARTITIONED BY (
    `datehour` string
)
ROW FORMAT SERDE      'org.openx.data.jsonserde.JsonSerDe'
STORED AS INPUTFORMAT 'org.apache.hadoop.mapred.TextInputFormat'
OUTPUTFORMAT          'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION              's3://amzn-s3-demo-bucket/route53-public-dns-query-logging/logs/aws_account_id/'
TBLPROPERTIES(
    'projection.enabled' = 'true',
    'projection.datehour.type' = 'date',
    'projection.datehour.range' = '1970/01/01/00,NOW',
    'projection.datehour.format' = 'yyyy/MM/dd/HH',
    'projection.datehour.interval' = '1',
    'projection.datehour.interval.unit' = 'HOURS',
    'storage.location.template' = 's3://amzn-s3-demo-bucket/route53-public-dns-query-logging/logs/aws_account_id/$${datehour}/'
)

確かにこれで公開 DNS クエリログを検索できるのですが、クエリの内容をみてもわかるとおり、ログの中身で最も知りたい筈の DNS クエリ周辺の状況 (logEvents[].message) が string として扱われるに留まっており、少々厄介です。例えば

{"messageType":"DATA_MESSAGE","owner":"123456789012","logGroup":"LOG_GROUP","logStream":"ZxxxxxxxxxxxxxxxxxxxQ/KUL51-R1","subscriptionFilters":["SUBSCRIPTION_FILTER"],"logEvents":[{"id":"39047082905970302202038872078382990508713243113432088576","timestamp":1750931754000,"message":"1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24"}]}

というログが有ったとして、この中で真に知りたいのは

1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24

であって、これを適当な列に分割したうえで列に対して具体的な値やパターン等を当て嵌めて検索するということが本来やりたいことです。とれる手段は

  • Data Firehose でのデータ処理時に Lambda 関数を噛ませて JSON ログ中 logEvents[].message だけを S3 へ送出する対象とする
  • ログは加工せず、Athena で頑張る

というものが考えられそうですが、弊社のケースでは先述の通り「お世話が必要な主体をあまり増やしたくない」ので、Athena で頑張る方法を選びました。具体的には

  • ログデータを直接扱い Athena 上で取り回しのしやすい構造にする為のテーブル
    • 上述の Athena テーブル定義による
  • ログデータから DNS クエリログに関する内容(= logEvents[].message)だけを検索対象とするビュー
    • 後述

といったようにテーブル以外に Athena ビュー*6 を用意することで対処しています。具体的には以下のようなビュー定義を使用しています。

-- Route 53 公開 DNS クエリログを Athena で扱うためのビュー
-- r53_public_dns_logs というテーブルを元ネタとして DNS クエリログを直接 Athena で検索できるようにするためのもの
CREATE OR REPLACE VIEW r53_public_dns_log_view AS
SELECT
    -- 正規表現を使い、message フィールドを仮想的な列に分割
    -- 正規表現の各( )がキャプチャグループ(1から始まるインデックス)に対応
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 1) AS version,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 2) AS timestamp,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 3) AS hosted_zone_id,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 4) AS name,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 5) AS type,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 6) AS response_code,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 7) AS protocol,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 8) AS edge_location,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 9) AS r_ip,
    regexp_extract(e.message, '^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$', 10) AS c_ip,
    l.datehour
FROM
    -- 対応する Athena テーブル名を指定する
    -- ログ内容は datehour によってパーティションが切られているのでビューでもこれを使えるようにする
    r53_public_dns_logs l
CROSS JOIN
    UNNEST(l.logEvents) AS t(e) -- 元のログにおける logEvents 配列を展開

ここで作成したビューを対象として検索を実施することで、公開 DNS クエリログもリゾルバクエリログと同等の使い勝手で Athena にて取り回すことが可能になります。

おわりに

Route 53 由来の DNS クエリログを Athena で取り扱う方法について解説しました。

S3 へのログ保存が公式にサポートされているリゾルバクエリログでは Athena による検索およびその運用に関する tips が数多く見付かる一方、公開 DNS クエリログについては CloudWatch Logs 以外の場所での保管を自前で頑張らないといけない事情で、ログ検索それ自体の tips は然程多くない現状があります。

両方の DNS クエリログを同等の手段(本記事では Athena + S3 ベースで)で横断して追跡できるようにすることで、ログ利用の手間感の低減や新たな洞察を得ることの切っ掛けになるはずです。実際に弊社ではこの手法で DNS クエリログを割合気軽に追えるようになったことで、これまであまりケアできていなかった DNS 関連の運用改善や外部からのリクエスト調査に新たな観点を導入するといった効果が得られ、予想よりも多くの嬉しさがありました。

DNS クエリログ収集やその運用改善に本稿が一助となれば幸いです。

MNTSQ 株式会社 SRE 秋本