Amazon ECSログ基盤の移行:CloudWatch LogsからAthenaへ
目次
こんにちは、L小川です。
突然ですが、みなさんはECSのログ検索には何を使っていますか?
面倒な設定が不要なCloudWatch Logsを使っている方も多いかと思います。
ただ、ログの量が多くなってくるとコストが気になるところです。
サービスの成長やユーザーの増加に伴い、気がついたらログの量もCloudWatch Logsの料金も増えていた、なんてことがあるのではないでしょうか。(弊社でもそういったケースがありました)
今回は、コスト削減・よりよい開発者体験のため、ECSのログ基盤をCloudWatch LogsからAthenaに移行した話をご紹介します。
CloudWatch Logsはログの取り込み料金が高い
ログの取り込みと保存料金について、CloudWatch LogsとFireLens・Firehose・S3で、ざっくりと比較してみます。
| CloudWatch Logs | FireLens・Firehose・S3 | |
|---|---|---|
| 取り込み | $0.76/GB | $0.076/GB |
| 保存 | $0.033/GB | $0.025/GB |
Firehoseログ取り込み料金内訳( Amazon Data Firehose の料金 – ストリーミングデータパイプライン – Amazon Web Services )
- 最初の 500 TB/月:$0.036
- Amazon S3 配信の動的パーティショニング
- 動的パーティショニングを通じて処理される GB あたり:$0.032
- 配信された 1,000 S3 オブジェクトあたり:$0.008
S3へのPUTやFirehoseでのLambda実行などの料金は反映していないので正確なものではありませんが、ログの保存先をCloudWatch LogsからS3に移行することでコスト削減できることは間違いありません。
構成

最終的に上記の構成となりました。
S3の日付パーティションはFirehoseでJST時刻にすべし
当初はFireLensから直接S3にログを転送していましたが、S3の日付パーティションがUTC時刻で区切られる仕様のため、Athenaでの検索が使いづらくなってしまいました。FireLensコンテナの環境変数や設定ファイルでJSTに変えようと試したものの、結果は変わりませんでした。Firehoseでは日付パーティションのタイムゾーンを指定できるので、「もっと早く気づいていれば……!」と思わずにいられませんでした。
また、Athenaは小さいサイズのファイルが多いとパフォーマンスが低下するということで、128MB以上のファイルサイズにすることが推奨されています。
Amazon Athena のパフォーマンスチューニング Tips トップ 10 | Amazon Web Services ブログ
Firehoseにはそのあたりもイイ感じに処理してくれる「バッファサイズ」「バッファ間隔」という設定があるので、Athenaを使うならFirehoseもセットで導入したいところです。
各種サービスの設定例
ECS
ログ取得対象コンテナ
「AWS Firelens 経由で Firehose にログをエクスポートする」を選択します。

FireLensコンテナ
Dockerイメージ
AWSが提供しているfluentbitのdockerイメージ「public.ecr.aws/aws-observability/aws-for-fluent-bit:init-latest」を指定します。
FireLensのログ
FireLensのログ出力先は下記の理由からCloudWatch Logsにしました。
- FireLensのログは起動・終了・エラー時にしか出力されない = ログの量は少なく、コスト面での懸念なし
- 設定ミスによる起動エラーはすぐに確認したい
- FireLensのログもFirehose→S3→Athenaにすると、FireLens用に各種リソースを作るのが面倒
不要なログを除外
FireLensからは以下のようなログが出力されます。(ECS on EC2の場合)
コンテナから出力されたログは”log”に格納されています。
|
1 2 3 4 5 6 7 8 9 10 |
{ "container_name": "/ecs-SMOOSYCloud-staging-99-SMOOSYCloud-app-staging-ea9edaa3b9a89f94aa01", "source": "stdout", "log": "{コンテナログ}", "container_id": "acd5f8b1958f4a2bf83e17a8821946b0adc9ece82746b0cab6be3a243a9ed306", "ec2_instance_id": "{EC2インスタンスID}", "ecs_cluster": "SMOOSYCloud-staging", "ecs_task_arn": "{タスクarn}", "ecs_task_definition": "{タスク定義名}:{リビジョン}" } |
S3に配置した設定ファイルで不要なログ情報を除外します。
環境変数のキーにaws_fluent_bit_init_s3_{連番}と指定することで、S3に配置したFluent Bit設定ファイルを読み込み、FireLensの挙動を変更できます。
| 環境変数 | キー | 値 |
|---|---|---|
| 設定値 | aws_fluent_bit_init_s3_1 | S3に配置した設定ファイルのarn |
|
1 2 3 4 5 6 |
[FILTER] Name record_modifier Match * Remove_key ecs_task_definition Remove_key source Remove_key container_id |
ECSタスクロール
Firehose・S3の各種権限を追加します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "firehose:PutRecord", "firehose:PutRecordBatch" ], "Resource": "arn:aws:firehose:ap-northeast-1:143559318231:deliverystream/{Firehoseストリーム名}" } ] } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor1", "Effect": "Allow", "Action": [ "s3:PutObject", "s3:GetObject", "s3:GetBucketLocation" ], "Resource": [ "arn:aws:s3:::{S3バケット}/*", "arn:aws:s3:::{S3バケット}" ] }, ] } |
Lambda
以下はFirehoseに送られたログを構文処理するLambdaのサンプルです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
import json import base64 import re from datetime import datetime, timedelta def lambda_handler(event, context): try: output = [] for record in event['records']: payload = json.loads(base64.b64decode(record['data'])) # container_name から環境名とコンテナ名を抽出 container_name = payload.get('container_name', '') # 環境名を抽出(staging / production / 他) env_match = re.search(r'(staging|pre|production)', container_name, re.IGNORECASE) environment = env_match.group(1).lower() if env_match else 'unknown' # コンテナ名を抽出(app / httpd / 他) container_match = re.search(r'(app|httpd)', container_name, re.IGNORECASE) container = container_match.group(1).lower() if container_match else 'unknown' # 以下のようにすることで # FirehoseのS3バケットプレフィックス # !{partitionKeyFromLambda:environment}, # !{partitionKeyFromLambda:container}, # に反映される record['metadata'] = { "partitionKeys": { "environment": environment, "container": container } } # JSONに改行を追加してNDJSON形式にする json_data = json.dumps(payload, ensure_ascii=False) + '\n' # エンコードして Firehose に返す record['data'] = base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') record['result'] = 'Ok' output.append(record) return {'records': output} except Exception as e: print(f"Error processing records: {e}") return {'error': str(e)} |
partitionKey
FireLensから転送されたログから環境名とコンテナ名を取得し、recordのmetadataにpartitionKeyを格納することで、環境・コンテナごとのS3フォルダにログを転送できるようにしています。
|
1 2 3 4 5 6 |
record['metadata'] = { "partitionKeys": { "environment": environment, "container": container } } |
※ FirehoseのS3 バケットプレフィックスに「!{partitionKeyFromLambda:environment}/!{partitionKeyFromLambda:container}/!{timestamp:yyyy/MM/dd}/」を設定します。(後述)
上記のLambdaには記載されていませんが、ログ検索でよく使うIPアドレスやステータスコード等の項目を取り出してpayloadに格納すれば、Athenaのクエリで検索しやすくなります。
※Athenaのテーブルに対応するカラムを作成しておく必要があります。
Firehose
レコードを変換および転換
上述のログ処理用Lambdaを指定します。

送信先の設定
- 動的パーティション:有効
- S3 バケットプレフィックス:!{partitionKeyFromLambda:environment}/!{partitionKeyFromLambda:container}/!{timestamp:yyyy/MM/dd}/
- S3 バケットエラー出力プレフィックス:{環境名}/firehose-error/!{firehose:error-output-type}/!{timestamp:yyyy/MM/dd}/
- S3 バケットと S3 エラー出力プレフィックスタイムゾーン:Asia/Tokyo

Athenaでは小さいサイズのファイルが多数あるとパフォーマンスが落ちるとのことで、128MBのバッファサイズが推奨されています。まずは推奨の値を設定してみて、その後様子を見ながら必要に応じて調整していくつもりです。
Athena
データベースを作成します。
|
1 |
CREATE DATABASE test_db; |
データベース名はケバブケースではエラーになってしまったので、スネークケースで作成しました。
Athena には、毎日増えるログのパーティション管理を自動化できる パーティション射影 という機能があります。
次に、このパーティション射影を使うテーブルを作成します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
CREATE EXTERNAL TABLE `httpd`( `time` string COMMENT 'from deserializer', `method` string COMMENT 'from deserializer', `path` string COMMENT 'from deserializer', `status_code` string COMMENT 'from deserializer', `response_time` string COMMENT 'from deserializer', `referer` string COMMENT 'from deserializer', `client_ip` string COMMENT 'from deserializer', `log_original` string COMMENT 'from deserializer', `ec2_instance_id` string COMMENT 'from deserializer', `ecs_task_arn` string COMMENT 'from deserializer') PARTITIONED BY ( `partition_date` string) 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://{バケット名}/{環境名}/httpd' TBLPROPERTIES ( 'classification'='json', 'compressionType'='gzip', 'projection.enabled'='true', 'projection.partition_date.format'='yyyy/MM/dd', 'projection.partition_date.interval'='1', 'projection.partition_date.interval.unit'='DAYS', 'projection.partition_date.range'='2024/11/20,NOW', 'projection.partition_date.type'='date', 'storage.location.template'='s3://{バケット名}/{環境名}/httpd/${partition_date}', 'transient_lastDdlTime'='1759476566' ) |
projection.enabled=’true’:このテーブルがパーティション射影を使うことを示します
projection.partition_date.format=’yyyy/MM/dd’:S3のフォルダ名の形式を指定します
storage.location.template:パーティション毎の実データの場所を定義します
ROW FORMAT SERDE ‘org.openx.data.jsonserde.JsonSerDe’:1行1JSONのログであることを示し、指定したカラムに値を自動的にマッピングできるようにします
元のログメッセージは log_original というカラムにそのまま保存しています。
これは、コンテナ起動時のログを Lambda の構文処理対象から外していることや、想定外のフォーマットでログの取り扱いに失敗した場合でも、元データを確認できるようにするためです。

つまずいたこと
S3にログファイルはあるのにログがAthenaに出てこない
S3にログファイルは置かれているのに、その内容がAthenaの検索結果に出てこないという事象が起きていました。
調べたところS3に置かれたログ・ファイルのフォーマットが原因でした。
Athenaのテーブル作成SQLのROW FORMAT SERDEで指定するorg.openx.data.jsonserde.JsonSerDeでは、以下のような1行 = 1JSONが想定されています。
|
1 2 3 |
{} {} {} |
OpenX JSON SerDe – Amazon Athena
FireLensからFirehose経由でS3に置かれたファイルは、一行に複数のJSONが連なっていたため、JSONパースエラーが起き、Athenaでの検索結果が空となっていました。
|
1 |
{}{}{} |
Firehoseで実行するLambdaでログデータに改行を加え、1行1JSONとしたところログがAthenaで表示できるようになりました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
record['metadata'] = { "partitionKeys": { "environment": environment, "container": container } } # JSONに改行を追加してNDJSON(Newline Delimited JSON)形式にする json_data = json.dumps(payload, ensure_ascii=False) + '\n' # エンコードして Firehose に返す record['data'] = base64.b64encode(json_data.encode('utf-8')).decode('utf-8') record['result'] = 'Ok' output.append(record) return {'records': output} |
本運用で発覚したFirehoseのスロットリング
本番環境のログをFireLensで転送するようにしたところ、以下のログがFireLensコンテナに出ていました。
|
1 2 3 4 5 |
[2025/11/19 18:52:59] [ info] [engine] flush chunk '1-1763545970.247708374.flb' succeeded at retry 1: task_id=15, input=forward.0 > output=kinesis_firehose.2 (out_id=2) [2025/11/19 18:52:51] [error] [output:kinesis_firehose:kinesis_firehose.2] Thoughput limits may have been exceeded, {Firehoseストリーム名} [2025/11/19 18:52:51] [error] [output:kinesis_firehose:kinesis_firehose.2] PutRecordBatch request returned with no records successfully recieved, {Firehoseストリーム名} [2025/11/19 18:52:51] [error] [output:kinesis_firehose:kinesis_firehose.2] Failed to send log records [2025/11/19 18:52:51] [error] [output:kinesis_firehose:kinesis_firehose.2] Failed to send records [2025/11/19 18:52:51] [ warn] [engine] failed to flush chunk '1-1763545970.247708374.flb', retry in 8 seconds: task_id=15, input=forward.0 > output=kinesis_firehose.2 (out_id=2) |
AWSサポートに聞いたところ、発生していた事象とその対応策について教えてもらえました。
FireLens→Firehose転送時にスロットリングが発生し、FireLens側でリトライしていたようです。
元々、Firehoseのスループット制限値であるBytesPerSecondLimitの上限は1MiB/秒に設定されています。
Amazon Data Firehose のクォータ – Amazon Data Firehose
スロットリングが発生するとFirehoseのBytesPerSecondLimitは自動でその上限を引き上げてくれます。下図にあるように、InComminBytes(上)のピークに合わせてBytesPerSecondLimit(下)の値が段階的に増加しています。


この上限値の自動引き上げは一時的なものではなく、一度上がった上限値はそのまま維持されています。
ログ転送に失敗してもFluent Bitの機能でリトライしてくれるのですが、そのデフォルト値は1となっています。スループット制限値の上限を自動で引き上げている間にスロットリングが発生し、リトライ数の上限に達してしまう可能性もあるため、ECSタスク定義 > 各コンテナのログ設定でRetry_Limitの値を増やしました。

上記の設定を反映したタスクを起動した後、FireLensコンテナの設定ファイルで設定が反映されていることを確認できました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
bash-4.2# cat /fluent-bit/etc/fluent-bit.conf [INPUT] Name forward Mem_Buf_Limit 25MB unix_path /var/run/fluent.sock [INPUT] Name forward Listen 127.0.0.1 Port 24224 [INPUT] Name tcp Tag firelens-healthcheck Listen 127.0.0.1 Port 8877 [FILTER] Name record_modifier Match * Record ec2_instance_id {インスタンスID} Record ecs_cluster {ECSクラスター名} Record ecs_task_arn {ECSタスクSRN} Record ecs_task_definition {ECSタスク定義} [OUTPUT] Name null Match firelens-healthcheck [OUTPUT] Name kinesis_firehose Match {コンテナ名A}-firelens* Retry_Limit 5 delivery_stream {Firehoseストリーム名} region ap-northeast-1 [OUTPUT] Name kinesis_firehose Match {コンテナ名B}-firelens* Retry_Limit 5 delivery_stream {Firehoseストリーム名} region ap-northeast-1 |
終わりに
今回はAthenaを利用するための具体的な設定方法や実際につまず いた話を共有しました。
Athenaに移行することでコスト削減だけでなく、ログ周りの情報が整理でき、調査時間の短縮が期待できます。
移行検討の際、この記事が参考になれば幸いです。
今後はログ出力指針も整備して、より効果的にログを活用できるようにしていければと考えています。






