🪡

Route 53 ResolverのクエリログをGrafana Cloudに送りたい

に公開

概要

Route 53 ResolverのクエリログはAmazon Data Firehoseに出力できます。
Amazon Data FirehoseはHTTPエンドポイントにデータを配信できるので、Grafana Lokiにクエリログを送ることができます。

本記事では、VPC LambdaでDNSクエリログを発生させ、Grafana Cloud上のLokiに送付してみます。
今回の構成
今回の構成

前提

Grafana CloudのLokiに書き込むために以下の準備をします。

LokiのHTTPエンドポイントとユーザーID確認

Grafana Cloudで以下のURLを参考にHTTPエンドポイントとユーザーを確認します。
HTTPエンドポイントは記載されている値を加工して使います。

項目 設定値
記載されている値 https://logs-prod-012.grafana.net
HTTPエンドポイント https://aws-logs-prod-012.grafana.net/aws-logs/api/v1/push

ユーザーIDは、Grafana Cloudの画面上でUserと表示されている6桁の値です。(2025/12/19時点)
例:345450

https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/aws/logs/firehose-logs/config-firehose-logs/#before-you-begin

ログの書き込み権限設定

Grafana CloudのLokiにログを書き込むためのトークンを以下のURLを参考に取得します。

https://grafana.com/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/create-access-policies/

用意したCFnテンプレート

今回のテスト環境は、VPC LambdaでDNSクエリを発生させ、VPC内のRoute 53 Resolverに問い合わせを実施します。
このRoute 53 ResolverのクエリログをAmazon Data Firehoseに送付する構成のため、VPC Lambda自体はインターネットへの疎通が不要であり、NAT GatewayやInternet Gatewayを作っていません。

CFnテンプレートのデプロイ時には以下のパラメータを設定する必要があります。

キー バリュー 備考
HTTPEndpointURL LokiのHTTPエンドポイント 例:https://aws-logs-prod-012.grafana.net/aws-logs/api/v1/push
LogsInstanceID ユーザーID LokiのHTTPエンドポイントとユーザー確認で確認した値。
例:345450
LogsWriteToken ログ書き込み権限のトークン ログの書き込み権限設定で取得したトークン

CFnテンプレート全体

CFnテンプレート全体
route53-resolver-firehose.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Route 53 Resolver Query Logs to Firehose to HTTP Endpoint (minimal)'

Parameters:
  HTTPEndpointURL:
    Type: String
    Description: 'HTTP endpoint URL to send logs to'

  LogsInstanceID:
    Description: Grafana Loki instance ID.
    Type: String

  LogsWriteToken:
    Description: Grafana Cloud token used to write to Loki.
    Type: String
    NoEcho: true

  BackupBucketName:
    Type: String
    Description: 'S3 bucket for Firehose backup (required by AWS)'
    Default: 'route53-resolver-backup'

  TestDomains:
    Type: CommaDelimitedList
    Default: 'example.com,example.net,example.org'
    Description: 'Comma-separated domains to resolve for test queries'

  ScheduleExpression:
    Type: String
    Default: 'rate(5 minutes)'
    Description: 'EventBridge schedule for periodic query tests'

  VPCCidr:
    Type: String
    Default: '10.0.0.0/16'
    Description: 'CIDR for the new VPC'

  PrivateSubnetCidr:
    Type: String
    Default: '10.0.10.0/24'
    Description: 'CIDR for private subnet'

Resources:
  # ==================== IAM Roles ====================
  FirehoseRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: firehose.amazonaws.com
            Action: 'sts:AssumeRole'
      Policies:
        - PolicyName: FirehoseS3Access
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 's3:PutObject'
                  - 's3:GetObject'
                  - 's3:ListBucket'
                Resource:
                  - !GetAtt BackupBucket.Arn
                  - !Sub '${BackupBucket.Arn}/*'

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
      Policies:
        - PolicyName: LambdaLogs
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*'

  # ==================== VPC (private-only, no NAT/IGW) ====================
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: route53-resolver-vpc

  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref PrivateSubnetCidr
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: route53-resolver-private-a

  # ==================== Security Group for Lambda ====================
  LambdaSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'Egress-only security group for resolver test Lambda'
      VpcId: !Ref VPC
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0

  # ==================== Backup S3 Bucket ====================
  BackupBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${BackupBucketName}-${AWS::AccountId}'
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  # ==================== VPC Lambda for Resolver Query Tests ===================
  QueryTesterFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: route53-resolver-query-tester
      Runtime: python3.12
      Architectures:
        - arm64
      Handler: index.handler
      MemorySize: 128
      Timeout: 30
      Role: !GetAtt LambdaExecutionRole.Arn
      VpcConfig:
        SecurityGroupIds:
          - !Ref LambdaSecurityGroup
        SubnetIds:
          - !Ref PrivateSubnet
      Environment:
        Variables:
          DOMAINS: !Join [',', !Ref TestDomains]
      Code:
        ZipFile: |
          import os
          import socket

          def handler(event, context):
            domains = os.environ.get('DOMAINS', 'example.com').split(',')
            results = []
            for name in domains:
              name = name.strip()
              if not name:
                continue
              try:
                socket.getaddrinfo(name, 80)
                results.append({'domain': name, 'status': 'ok'})
              except Exception as exc:  # noqa: BLE001
                results.append({'domain': name, 'status': 'error', 'error': str(exc)})
            return {'results': results}

  QueryTesterSchedule:
    Type: AWS::Events::Rule
    Properties:
      Name: route53-resolver-query-tester-schedule
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt QueryTesterFunction.Arn
          Id: QueryTesterTarget

  QueryTesterInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref QueryTesterFunction
      Action: 'lambda:InvokeFunction'
      Principal: events.amazonaws.com
      SourceArn: !GetAtt QueryTesterSchedule.Arn

  # ==================== Data Firehose ====================
  QueryLogDeliveryStream:
    Type: AWS::KinesisFirehose::DeliveryStream
    Properties:
      DeliveryStreamName: route53-resolver-to-http
      DeliveryStreamType: DirectPut
      HttpEndpointDestinationConfiguration:
        EndpointConfiguration:
          Url: !Ref HTTPEndpointURL
          AccessKey: !Sub '${LogsInstanceID}:${LogsWriteToken}'
          Name: HTTPEndpoint
        BufferingHints:
          SizeInMBs: 5
          IntervalInSeconds: 60
        RetryOptions:
          DurationInSeconds: 3600
        RoleARN: !GetAtt FirehoseRole.Arn
        S3BackupMode: FailedDataOnly
        S3Configuration:
          BucketARN: !GetAtt BackupBucket.Arn
          RoleARN: !GetAtt FirehoseRole.Arn

  # ==================== Route 53 Resolver Query Logging ====================
  QueryLoggingConfig:
    Type: AWS::Route53Resolver::ResolverQueryLoggingConfig
    Properties:
      DestinationArn: !GetAtt QueryLogDeliveryStream.Arn
      Name: route53-resolver-query-logs

  QueryLoggingConfigAssociation:
    Type: AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation
    Properties:
      ResolverQueryLogConfigId: !GetAtt QueryLoggingConfig.Id
      ResourceId: !Ref VPC

Outputs:
  DeliveryStreamArn:
    Description: 'Kinesis Data Firehose Delivery Stream ARN'
    Value: !GetAtt QueryLogDeliveryStream.Arn

  DeliveryStreamName:
    Description: 'Kinesis Data Firehose Delivery Stream Name'
    Value: !Ref QueryLogDeliveryStream

  QueryLogConfigId:
    Description: 'Route 53 Resolver Query Logging Config ID'
    Value: !GetAtt QueryLoggingConfig.Id

  BackupBucketName:
    Description: 'S3 Backup Bucket Name'
    Value: !Ref BackupBucket

  QueryTesterFunctionName:
    Description: 'Lambda function for periodic resolver query tests'
    Value: !Ref QueryTesterFunction

  VpcId:
    Description: 'Created VPC ID'
    Value: !Ref VPC

  PrivateSubnetId:
    Description: 'Created Private Subnet ID'
    Value: !Ref PrivateSubnet

CFnテンプレートのデプロイ

以下のようにパラメータを指定してCFnテンプレートをデプロイします。
CFnでIAMロールもデプロイするために --capabilities CAPABILITY_NAMED_IAM を指定しています。
--parameter-overrides に指定しているパラメータは例示のため、環境に応じた値に修正が必要です。

aws cloudformation deploy \
--template-file route53-resolver-firehose.yaml \
--stack-name route53-resolver-firehose \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
  HTTPEndpointURL=https://aws-logs-prod-XXX.grafana.net/aws-logs/api/v1/push \
  LogsInstanceID=123456 \
  LogsWriteToken=glc_eyJ*****  #ログ書き込み権限のトークン

Grafana Cloudでの確認

Grafana CloudのExploreでLogを確認できました。
オブザーバビリティーの可視化にGrafana Cloudを使っている場合でも、Route 53 Resolverのクエリログも結構簡単に集約できそうです。

Grafana CloudのExplore
Grafana CloudのExplore

Discussion