💸

Athenaによるアプリケーションログ分析でCloudWatch Logsのコストを削減する

2024/02/23に公開

AWSコストの最適化は、AWS Well-Architectedにおける6つの柱の1つに掲げられており、ビジネスにおいて適正な利益を維持する意味でも、避けて通れないトピックの1つです。
最近ではAWSコスト削減 天下一武道会の盛り上がりも記憶に新しいところですね。

今回は、CloudWatch Logsのコスト削減の取り組みをご紹介します。

CloudWatch Logsのコストが高い?

体系立ててコスト最適化を進めるためのアプローチについては、公式のドキュメントや書籍「AWSコスト最適化ガイドブック」に説明を譲るとして、実際にコストエクスプローラ等でコストの内訳を見ると、CloudWatchのコストが上位にいることは、これまでの経験上、よくある話でした。

ストレージ等でももちろんコストは発生しますが、ログを送るところでコストがかかるので、無闇に大量のログを送るのではなく、適正量のログを送ることが重要になってきます。
具体的なコスト感ですが、東京リージョンの場合、ログの収集(データインジェスト)に USD 0.76/GB かかります[1]

https://aws.amazon.com/jp/cloudwatch/pricing/

ロググループごとの収集量はIncoming Bytesで確認

まずはCloudWatchメトリクスで、 ロググループ単位 でのデータ量を見るとよいかなと思います。

量の多いロググループを見極めるために、CloudWatchのIncomingBytesメトリクスが利用可能です。このメトリクスは「CloudWatch Logsにアップロードされたログイベントのボリューム (非圧縮バイト数)」であり、つまり、ロググループ単位での収集データ量がわかります。

Cariotでは、主に以下のようなものをCloudWatch Logsで収集しています。

  • Lambdaのログ(Lambda関数ごと)
  • アプリケーションログ(アプリケーションの種類ごと)
  • RDSログ

メトリクスを見る場合は、統計を「合計」にし、期間内の合計量を確認しましょう。
ログ量が問題になるほど大きなサイズでなければ、削減効果は薄くなるので、ログ出力量を見直すべきかどうかの判断材料となります。

https://repost.aws/ja/knowledge-center/cloudwatch-logs-bill-increase

アプリケーションログの大量出力クラスを見定める

※ ここから先は、LambdaやRDSのログ量には問題がなく、あるアプリケーションからのログが大量に送られていた、というシナリオの想定でお読みください。

アプリケーションログのサイズの適正化や異常の解消を図ろうと思った場合、 処理(クラス、メソッド)単位 で、ログの傾向を分析する必要が出てきます。

過去、Athenaのような分析基盤が整備されていなかった時は、ログファイルを手元にダウンロードし、目検でどんな出力が多いのかを調べたりして確認していました。
ただしこの方法は、ログファイルをダウンロードするコストもかかりますし、俯瞰的・横断的な分析ではないので、ヨミを外した場合には試行錯誤が必要で、あまり効率的ではありません。

ログ分析基盤を整えることで、再現性高く簡単に分析ができるようになるので、ログ分析の1つの利用ケースとして、今回紹介します。

アプリケーションログ収集基盤の構成

Cariotでは、アプリケーションはJava(Spring Boot)を使っており、実行環境はECS+Fargateなので、ログ収集・分析のアーキテクチャは以下のようにしています。

特別変わったことはしていない(と思っている)のですが、アプリケーションから出力ログについては、ファイル出力ではなく、JSONで標準出力に出すようにしています。
これは、ログを扱いやすくする上でJSONである方が都合がよく、また、ECSのサイドカーコンテナを使った構成ではログは標準出力に出すのが定石だからですね。
そのためのLogbackの設定(抜粋)は以下のものを使います。

logback-spring.xml
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSS'Z'</timestampPattern>
        </encoder>
    </appender>

アプリケーションログ分析用のテーブルを作成する

データベースにテーブルを作成するためのDDLは以下のものを用います。テーブル名やS3のパスは読み替えてください。

CREATE EXTERNAL TABLE `app1_application_log`(
  `@timestamp` string, 
  `@version` string, 
  `logger_name` string, 
  `thread_name` string, 
  `message` string, 
  `level` string, 
  `level_value` string, 
  `ecs_cluster` string, 
  `ecs_task_arn` string, 
  `ecs_task_definition` string)
PARTITIONED BY ( 
  `day` string, 
  `hour` int)
ROW FORMAT SERDE 
  'org.openx.data.jsonserde.JsonSerDe' 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.mapred.TextInputFormat' 
OUTPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.IgnoreKeyTextOutputFormat'
LOCATION
  's3://bucket-1234567890/logs/app1/application_log'
TBLPROPERTIES (
  'projection.day.format'='yyyy/MM/dd', 
  'projection.day.interval'='1', 
  'projection.day.interval.unit'='DAYS', 
  'projection.day.range'='2023/04/01,NOW', 
  'projection.day.type'='date', 
  'projection.enabled'='true', 
  'projection.hour.digits'='2', 
  'projection.hour.range'='0,23', 
  'projection.hour.type'='integer', 
  'storage.location.template'='s3://bucket-1234567890/logs/app1/application_log/${day}/${hour}/', 
  'transient_lastDdlTime'='1706876216')

テーブル定義中のカラムですが、LogstashEncoderが出力するものと、FireLensが出力するものが混在しています。
以下はLogstashEncoderが出力します。

  • @timestamp
  • @version
  • logger_name(ロガー名=クラスのFQCN)
  • thread_name(ログ出力実行スレッド)
  • message(ログメッセージそのもの)
  • level(ログレベル=TRACE/DEBUG/INFO/WARN/ERROR)
  • level_value(ログレベル値[2]

残りはFireLensが出力するフィールドです。

  • ecs_cluster
  • ecs_task_arn
  • ecs_task_definition

今回は未使用ですが、上記以外の独自のカラムを追加する場合は、Logbackの設定やFluent Bitの設定で行うことができます。

パーティション射影によるパーティション管理自動化

DDL中に PARTITIONEDTBLPROPERTIES といった記述が含まれていますが、パーティション管理を自動化するために、パーティション射影(Partition Projection)を使っています。

https://docs.aws.amazon.com/athena/latest/ug/partition-projection.html

Athenaではクエリ時にスキャンした容量によって従量課金されますので、ログデータのような大量のデータをクエリの度に全スキャンするのはコストがかかります[3]、パーティションで区切って、必要なデータ範囲だけをスキャンできるようにします。

今回は日付(day)と時間(hour)をパーティションとして利用しています。以下のようなイメージです。

Hive形式と非Hive形式について

パーティション管理を意識するにあたり、S3のキーの設計についても考えておく必要があります。
キーの形式には、Hive形式と非Hive形式があり、まとめると以下のようになります。今回は非Hive形式を採用しています。

  • Hive形式
    • /date={yyyy-mm-dd}/hour={hh}の形式
    • パーティションは自動的に検出されるため、手動でのパーティション追加が 不要
    • パーティションは自動で検知されるが、パーティションの更新は定期的に必要。方法としては、定期起動するLambdaから MSCK REPAIR TABLE を実行する方法や、 Glue クローラを使う方法がある。
    • FirehoseからS3に出力する場合、 昔は Hive形式での出力ができなかった。が、2019年2月からカスタムAmazon S3プレフィックスがサポートしており[4]、この留意点も過去の話になっている
    • パーティション射影によるパーティション管理の自動化は できる
  • 非Hive形式
    • /yyyy/mm/dd/hh/の形式
    • ALTER TABLE ADD PARTITION で手動でのパーティション追加が 必要
    • FirehoseからS3に出力する場合、デフォルトはこちらの形式
    • パーティション射影によるパーティション管理の自動化は できる

「どっちにすべきか?」については、ケースバイケースではあるものの、現在においては、どちらも大きな差はないかなと思います(が、間違っていたらご指摘ください)
パーティション射影が使えるようになったのは2020年6月[5]なので、それ以前であれば、Hive形式、非Hive形式の違いを意識して、考える必要があったんだろうと思いますが…。

アプリケーションのログ量分析SQL

長くなりましたが、準備ができたので、いよいよクエリを実行します。

分析クエリは以下のようなものになりました。
このクエリは、2024年1月23日の14時台のログを分析するためのもので、ログの出力文字数が多い順に上位50件のクラスを抽出します。

WITH source as (
  SELECT
    logger_name,
    LENGTH(message) AS message_len,
    level
  FROM
    "database1"."app1_application_log"
  WHERE
    day = '2024/01/23'
    AND hour = 5
)
SELECT
  logger_name,
  SUM(message_len) as total_message_size
FROM
  source
GROUP BY
  logger_name, level
ORDER BY
  SUM(message_len) DESC
LIMIT 50;

パーティションで絞るために day は必ず指定するようにしてください。

LENGTH で文字数が取得できますので、これを使って文字数の合計を集計しています。

実行結果

うまくクエリできていそうです!

あとは、出力の多いクラスのプログラムと、実際のログを見ながら、地道にログの出力を最適化していくことになります。


というわけで、Athenaによるアプリケーションログ分析の一例でした。

ログの「検索」に関しては、CloudWatch Logs InsightsやOpenSearchの方がやりやすい部分もあったりしますが、ログの「分析」においては、Athenaを使うと、便利なユースケースが色々とありそうです。

脚注
  1. 2024年2月時点。スタンダードの場合 ↩︎

  2. TRACE(5000), DEBUG(10000), INFO(20000), WARN(30000), ERROR(40000) ↩︎

  3. スキャン事故の予防としても、Athenaクエリを実行する場合は、ワークグループを有効にし、ワークグループでクエリあたりの制限を指定しておきましょう。 ↩︎

  4. Amazon Kinesis Data Firehose がカスタム Amazon S3 プレフィックスのサポートを発表 ↩︎

  5. Amazon Athena が Partition Projection のサポートを追加 ↩︎

株式会社キャリオット

Discussion