インフラチームがお届けするブログリレーです!既に公開されている記事もありますので、こちらから他のメンバーの投稿もぜひチェックしてみてください!

はじめに

こんにちは、一番好きなAWSサービスは「Amazon Athena」のMitsuoです。
CloudFrontの標準アクセスログが昨年の11月に強化されたことをご存知ですか?
記事にしようと思ってから早半年、時の流れというのは早いものです……(筆不精で遅くなってしまいました)。
Amazon CloudFront now supports additional log formats and destinations for access logs – AWS
というわけで、今回は改めて仕様を整理し、実際に試してみた内容をお届けします。

アップグレード内容

  • Amazon S3に加えて、Amazon CloudWatch LogsやAmazon Data Firehoseへの送信が可能になりました。
  • 従来版の標準ログ記録(以降、「レガシー」と記載)で選択できたログフィールドに加え、リアルタイムログのフィールドも選択可能になりました。
    リアルタイムログで選択できる主なフィールドは以下の通りです。
項目 内容
timestamp(ms) ミリ秒単位のタイムスタンプ
origin-fbl CloudFrontとオリジン間の先頭バイトのレイテンシー(秒)
origin-lbl CloudFrontとオリジン間の最後のバイトのレイテンシー(秒)
asn ビューワーのASN番号
c-country ビューワーの国コード(IPアドレスによって識別)
cache-behavior-path-pattern キャッシュ動作を識別するパスパターン
  • 出力ファイル形式が、レガシー版で選択できたW3C形式に加え、JSON、Plain、Parquet(Amazon S3のみ)も選択可能になりました。
  • 送信先がS3の場合、パーティショニングが設定可能になりました。
    個人的に最も嬉しいのが、このパーティショニング機能です。
    レガシー版では、指定したS3バケットの特定のプレフィックス配下にすべてのアクセスログがまとめて保管されていました。この仕様では、Amazon Athenaでクエリを実行する際に、特定の日付のログを分析したい場合でも全データをスキャン(フルスキャン)する必要があり、効率が非常に悪かったです。 クエリ効率を上げるために、このため、Lambdaなどでスクリプトを組んで、ログのパスを日付ごとに変更する様な方もいたのではないでしょうか。

マネジメントコンソールで確認してみる

CloudFrontディストリビューションの「Logging」タブから設定変更が可能です。「Add」ボタンを押すと、レガシー版に加えて標準ログv2の送信先が選択できます。

比較のため、まずは「Amazon S3 (レガシー)」を選択してみます。すると、送信先のS3バケットとログプレフィックスしか指定できず、すべてのログが単一のパスに保存される設定になっていることがわかります。

次に、Amazon S3(v2版)を選択します。
Additinal settings – optinalのプルダウンをクリックすると、
「Additional settings – optional」のプルダウンを開くと、フィールド選択、パーティショニング、出力フォーマット(Output format)、区切り文字(Field delimiter)などを細かく設定できることがわかります。

フィールド選択では、ログレコードに含めるフィールドとその順序を指定できます。
デフォルトのフィールドセットには含まれていませんが、前述のリアルタイムログのフィールドも選択できることがわかります。

パーティショニングでは、ログの格納パスを動的に分割するためのサフィックス(標準ログの文脈だと接頭辞)を設定します。

標準ログv2では、以下のような変数を使用できます。
これにより、1つのバケット内にディストリビューションごと、さらに年・月・日・時間ごとにパスを分けてログを格納できます。
この構造は、Amazon Athenaのパーティション射影(Partition Projection)と非常に相性が良く、クエリパフォーマンスの向上とコスト削減に繋がります。

  • {DistributionId}、または {distributionid}
  • {yyyy}
  • {MM}
  • {dd}
  • {HH}
  • {accountid}

「Output format」では、W3C形式の他に追加されたフォーマットを選択できることがわかります。

「Field delimiter」では、ログの区切り文字を選択します。これは、出力フォーマットがW3CまたはPlain形式の場合に設定可能です。
以下のスクリーンショットは、W3C形式で選択できる区切り文字の一覧です。

実際に試してみる

Terraformを使い、CloudFront + S3の環境を構築して、実際にログが出力されるまでを確認します。

Terraform

前提

  • ローカル環境からTerraformコマンドを実行します。
    • 実行環境:MacBook M2
    • プロバイダー:hashicorp/aws v5.96
    • Terraform version:1.11.4
  • tfstate用のS3バケット作成やTerraformの実行コマンドについては、本記事では割愛します。
  • 以降に紹介するtfファイルと別でdata sourcevariableの定義ファイルが必要です。
# Current AWS Account ID
data "aws_caller_identity" "current" {}
# Current User ID with AWS Account
data "aws_canonical_user_id" "current" {}

variable "system" {
  default = "ここにシステムプレフィックスを入れる"
}

variable "env" {
  type    = string
  default = "ここに環境名を入れる"
}

variable "domain_name" {
  type    = string
  default = "ここにドメイン名を入れる"
}

variable "domain_id" {
  type    = string
  default = "登録済みのホストゾーンで検証する場合は、data sourceで指定、またはこの変数を指定する"
}
  • AWSリソースは、バージニア北部リージョン(us-east-1)で作成します。参考までに、以下のようなプロバイダー設定を追記します。
provider "aws" {
  alias  = "n-virginia"
  region = "us-east-1"
}

cloudfront.tf

このファイルでは、以下のリソースを作成・設定します。

  • CloudFrontのディストリビューション
  • オリジンアクセスコントロール(OAC)
  • 標準ログv2出力設定
  • オリジン用バケット
  • 標準ログv2用バケット

標準ログv2の出力設定は、ディストリビューションリソース内で直接定義するのではなく、aws_cloudwatch_log_delivery_sourceaws_cloudwatch_log_delivery_destination
aws_cloudwatch_log_deliveryという3つのリソースを組み合わせて構成します。
コードを見ると、aws_cloudwatch_log_delivery_destinationで出力形式をJSONに設定し、
aws_cloudwatch_log_deliveryで保管先のパス(suffx path)を指定していることが分かります。

# -----------------------------
# OAC
# -----------------------------
locals {
  front_oac_name = "${var.system}-${var.env}-cloudfront-oac"
}

resource "aws_cloudfront_origin_access_control" "main" {
  name                              = local.front_oac_name
  description                       = local.front_oac_name
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# -----------------------------
# ディストリビューション
# -----------------------------
locals {
  distribution_name     = "${var.system}-${var.env}-distribution"
  s3_origin_id          = "S3-${var.system}-${var.env}-frontend"
}

# キャッシュポリシー
data "aws_cloudfront_cache_policy" "optimized" {
  name = "Managed-CachingOptimized"
}

data "aws_cloudfront_cache_policy" "disabled" {
  name = "Managed-CachingDisabled"
}

data "aws_cloudfront_origin_request_policy" "all_viewer" {
  name = "Managed-AllViewer"
}

# ディストリビューション
resource "aws_cloudfront_distribution" "main" {
  aliases = [
    "mitsuocf.${var.domain_name}"
  ]
  comment    = local.distribution_name

  origin {
    domain_name              = aws_s3_bucket.main.bucket_regional_domain_name
    origin_id                = local.s3_origin_id
    origin_access_control_id = aws_cloudfront_origin_access_control.main.id
  }

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = local.s3_origin_id
    cache_policy_id        = data.aws_cloudfront_cache_policy.optimized.id
    compress               = true
    viewer_protocol_policy = "https-only"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.cert_virginia.arn
    minimum_protocol_version = "TLSv1.2_2021"
    ssl_support_method       = "sni-only"
  }

  tags = {
    Name = local.distribution_name
  }
}

# -----------------------------
# 標準ログv2
# -----------------------------
locals {
  cloudfront_log_delivery_source_name         = "${var.system}-${var.env}-cloudfront-log-delivery-source"
  cloudfront_log_delivery_destination_name    = "${var.system}-${var.env}-cloudfront-log-delivery-destination"
}

resource "aws_cloudwatch_log_delivery_source" "main" {
  provider     = aws.n-virginia
  name         = local.cloudfront_log_delivery_source_name
  log_type     = "ACCESS_LOGS"
  resource_arn = aws_cloudfront_distribution.main.arn
}

resource "aws_cloudwatch_log_delivery_destination" "main" {
  provider = aws.n-virginia
  name     = local.cloudfront_log_delivery_destination_name

  output_format = "json"

  delivery_destination_configuration {
    destination_resource_arn = aws_s3_bucket.cloudfront_access_logs.arn
  }
}

resource "aws_cloudwatch_log_delivery" "main" {
  provider                 = aws.n-virginia
  delivery_source_name     = aws_cloudwatch_log_delivery_source.main.name
  delivery_destination_arn = aws_cloudwatch_log_delivery_destination.main.arn

  s3_delivery_configuration {
    suffix_path                 = "{DistributionId}/{yyyy}/{MM}/{dd}/{HH}"
    enable_hive_compatible_path = false
  }
}

# -----------------------------
# S3(Frontend)
# -----------------------------
locals {
  s3_name_frontend = "${var.system}-${var.env}-bucket-frontend"
}

resource "aws_s3_bucket" "main" {
  bucket = local.s3_name_frontend

  tags = {
    Name = local.s3_name_frontend
  }
}

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

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

data "aws_iam_policy_document" "main" {
  policy_id = "PolicyForCloudFrontPrivateContent"

  statement {
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.main.arn}/*"]
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [aws_cloudfront_distribution.main.arn]
    }
  }
}

# -----------------------------
# S3(CloudFront Access Logs)
# -----------------------------
locals {
  s3_name_cloudfront_access_logs = "${var.system}-${var.env}-bucket-cloudfront-access-logs"
}

resource "aws_s3_bucket" "cloudfront_access_logs" {
  bucket = local.s3_name_cloudfront_access_logs

  tags = {
    Name = local.s3_name_cloudfront_access_logs
  }
}

data "aws_iam_policy_document" "cloudfront_access_logs" {
  version = "2012-10-17"
  statement {
    sid       = "AWSLogDeliveryWrite"
    effect    = "Allow"
    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.cloudfront_access_logs.arn}/*"]
    principals {
      type        = "Service"
      identifiers = ["delivery.logs.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values   = ["bucket-owner-full-control"]
    }
    condition {
      test     = "ArnLike"
      variable = "aws:SourceArn"
      values   = ["arn:aws:logs:us-east-1:${data.aws_caller_identity.current.account_id}:delivery-source:*"]
    }
  }
}

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

CloudFrontからS3バケットに標準ログを出力するために、バケットポリシーの設定が必要です。上記のtfファイルを実行すると以下の様なポリシーが作成されます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AWSLogDeliveryWrite",
            "Effect": "Allow",
            "Principal": {
                "Service": "delivery.logs.amazonaws.com"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::`バケット名`/*",
            "Condition": {
                "StringEquals": {
                    "aws:SourceAccount": "`AWSアカウントID`",
                    "s3:x-amz-acl": "bucket-owner-full-control"
                },
                "ArnLike": {
                    "aws:SourceArn": "arn:aws:logs:`リージョン名`:`AWSアカウントID`:delivery-source:*"
                }
            }
        }
    ]
}

acm.tf

証明書のリソースを作成します。

resource "aws_acm_certificate" "cert_virginia" {
  provider                  = aws.n-virginia
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  validation_method         = "DNS"
  tags = {
    Name = "${var.env}-${var.system}-virginia-cert"
  }
}

resource "aws_acm_certificate_validation" "cert_virginia" {
  provider                = aws.n-virginia
  certificate_arn         = aws_acm_certificate.cert_virginia.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation_virginia : record.fqdn]
}

route53.tf

DNSレコードの関連リソースを作成します。
なお、今回は既存のホストゾーンにAレコードを追加するため、ホストゾーンを新規作成するリソースはコメントアウトしています。

# -----------------------------
# Route53 HostZone(Public)
# -----------------------------

#resource "aws_route53_zone" "main" {
#  name    = var.domain_name
#  comment = "Hosted zone for mitsuo"
#  tags = {
#    Name = var.domain_name
#  }
#}

# -----------------------------
# Cert validation
# -----------------------------
resource "aws_route53_record" "cert_validation_virginia" {
  for_each = {
    for dvo in aws_acm_certificate.cert_virginia.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = var.domain_id

}

resource "aws_route53_record" "cloudfront_main" {
  zone_id = var.domain_id
  name    = "mitsuocf.${var.domain_name}"
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.main.domain_name
    zone_id                = aws_cloudfront_distribution.main.hosted_zone_id
    evaluate_target_health = false
  }
}

オリジン用S3バケット

S3バケットのルートに、index.htmlimage.jpgをアップロードします。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>テスト</title>
</head>
<body>
    <h1>勉強になります</h1>
    <img src="image.jpg" alt="テストハヤブサ" width="300" height="300">
</body>
</html>

image.jpg

標準ログ(v2)の出力確認

設定したURLへアクセスした後、ログ出力用のS3バケットを確認してみましょう。Terraformで指定したパス形式で、標準ログが出力されていることがわかります。

設定ファイルで指定したサフィックス {DistributionId}/{yyyy}/{MM}/{dd}/{HH} の通りに、標準ログが作成されていますね。

最後に

いかがだったでしょうか。個人的には、CloudFrontのアクセスログを頻繁に分析するユーザーにとって、非常に嬉しいアップデートだったと思います。
今後はこの新しい標準ログをデータソースに、Athenaでの高度な分析やOpenSearchへのデータ取り込みなども試してみたいです。

この記事がどなたかのお役に立てば幸いです。
勉強になります、Mitsuoでした。

参考資料

Configure standard logging (v2)