ECS FargateのLoki / Tempoが落ちたときに失うデータを検証してみた

ECS FargateのLoki / Tempoが落ちたときに失うデータを検証してみた

ECS Fargateに置いたLoki / Tempoのタスクが落ちたときのデータ損失を検証してみました。ログはflushされれば失いませんが、トレースは直近30秒分が消えること、そしてflushされていないログは全て失われることを確認しました。
2026.09.10

こんにちは、ゲームソリューション部のsoraです。
今回は、ECS Fargateに置いたLoki / Tempoのタスクが落ちたときに、どれだけデータを失うのかを検証してみたことについて書いていきます。

以前ECS FargateでGrafanaをセルフホストしたときは、保存先をCloudWatch / X-Ray / AMPに寄せていました。
https://dev.classmethod.jp/articles/grafana-ecs-fargate-aurora/

今回はその保存先をセルフホストのLoki / Tempoに置き換えます。
どちらもローカルにためてからS3に書くので、Fargateのエフェメラルなストレージだと、ためている最中に落ちた分は消えます。

今回は以下の点を検証しました。

  • Loki / Tempoが「ためてからS3へ」書いていることを、S3のオブジェクトで確認すること
  • タスクを強制停止したときに、TempoとLokiでそれぞれどれだけ失うか

構成

今回構築したのは以下の構成です。
sr-loki-tempo-fargate-00

Grafana・Loki・Tempo・サンプルアプリは、すべて別々のECSタスクとしてFargateで動かしています。
1つのタスクに詰め込んではいません。

サンプルアプリのログはFireLensがLokiへ、トレースはADOT CollectorがTempoへ送ります。
Lokiはログを、Tempoはトレースをいったんローカルにためてから、それぞれのS3バケットに書きます。
メトリクスはアプリから送っておらず、Tempoのmetrics-generatorがトレースから生成してAMPにremote writeしています。

ADOTからはX-Rayにも同じトレースを送っています。
Tempoが落ちたときに同じトレースがX-Ray側に残っていることを見せるためです。

使用したバージョンは以下のとおりです。

  • Grafana: 13.2.1
  • Loki: 3.7.7
  • Tempo: 3.0.3

Grafanaの設定DBは前回と同じくAurora Serverless v2で、Loki / TempoへはCloud Mapで名前解決してプライベートサブネット内で通信します。

ためてからS3へ書く仕組み

Loki / Tempoが「どれくらいためてからS3に書くか」は設定で決まります。
ここが今回の検証の中心なので、先に整理しておきます。

Loki

Lokiは、ラベルの組み合わせごとにログをストリームとしてまとめます。
そのストリームに来たログ行をchunkとしてメモリ上に積んでいき、条件を満たしたらflushします。

ストリームがラベルで決まる論理的な単位、chunkが時間で区切られる保存の単位という関係です。
1つのストリームにchunkがいくつもぶら下がります。

flushの条件は主に以下の2つです。

パラメータ 既定 役割
chunk_idle_period 30m 書き込みが止まったストリームのchunkをflushするまでの時間
max_chunk_age 2h chunkを作り始めてからflushするまでの最大時間

https://grafana.com/docs/loki/latest/configure/

なお、既定の2時間のままだとS3に落ちる様子を観察できないので、動作確認から検証2まではmax_chunk_ageを3分に縮めています
既定に戻すのは検証3です。

Tempo

今回使ったTempoは3.0系です。
2026年8月に出た3.0で、内部のコンポーネントがingester / compactorからlive_store / block_builderに置き換わりました。
2.xの設定はそのままでは使えません。

https://grafana.com/docs/tempo/latest/set-up-for-tracing/setup-tempo/migrate-to-3/

Tempoにはデプロイモードが2つあり、今回は1プロセスで全部の役割を動かすmonolithicモードです。
もう一方のmicroservicesはKafkaが前提なので、1タスクで動かす今回の構成では選べません。

Tempoも、受け取ったスパンをいったんためて、ブロックという単位にまとめてからS3に書きます。
ためている役は、3.0で新しくなったlive_storeというコンポーネントです。

パラメータ 既定 役割
max_block_duration 30s ブロックを切るまでの最大時間

https://grafana.com/docs/tempo/latest/configuration/manifest/

サイズで切るmax_block_bytes(既定50MB)もありますが、今回の流量では先に30秒のほうに当たります。

block_builderはKafkaからデータを読むmicroservices専用のコンポーネントで、monolithicでは動いていません。

構築

今回もTerraformで構築しました。
基本的なリソースが多いのでコードは割愛します。

1点だけ、ECSサービスのminimum_healthy_percentを0にしています。
こうすると入れ替えのときに新旧のタスクが同時に動かないので、1タスク構成の挙動をそのまま観察できます。

動作確認

データソースの登録

左メニューの Connections → Data sources → Add new data source から、Loki・Tempo・AMP・X-Rayの4つを登録します。
Loki / TempoはCloud Mapの名前をURLに入れるだけです。
AMPとX-Rayはプラグインなので、GF_INSTALL_PLUGINSで起動時に入れています。

それぞれ Save & test でOKが出ることを確認します。

sr-loki-tempo-fargate-01

Lokiでログ確認、Tempoでトレース確認

サンプルアプリには、正常に応答するエンドポイントとエラーを返すエンドポイントを用意しています。
これらに数分間アクセスして、ログ・トレース・メトリクスを溜めておきます。

Exploreでデータソースに「Loki」を選択し、{job="app"}でクエリを実行してみると、frontend / backendのアクセスログが確認できました。

sr-loki-tempo-fargate-02

10分間で1,129行入っています。
上部のログ量のヒストグラムが途切れなく並んでいるのが、このあと比較する「正常な状態」です。

データソースに「Tempo」を選択し、Query typeをSearchにして実行すると、トレースの一覧が表示されました。

sr-loki-tempo-fargate-04

1つ開いてみると、frontend → HTTP GET → backend → sql.conn.querysql.rowsの6スパンが並びます。

sr-loki-tempo-fargate-05

AMPでメトリクス確認

今回、アプリはメトリクスを送っていません。
送っているのはトレースだけで、そのトレースを材料にしてTempoがメトリクスを組み立てています。

これはTempoのmetrics-generatorという機能で、今回は2つのプロセッサを有効にしています。

overrides:
  defaults:
    metrics_generator:
      processors: ["span-metrics", "service-graphs"]

span-metricsは、受け取ったスパンを「スパン名 × ステータス」で数えて、呼び出し回数・エラー数・レイテンシのヒストグラムにします。
service-graphsは、スパンの親子関係から「どのサービスがどのサービスを呼んだか」を集計します。
どちらもPrometheusのremote write形式でAMPに書かれます。

アプリ側にメトリクスの計装を入れなくてもRate / Errors / Durationが手に入るのが利点です。
一方で、Tempoが止まればメトリクスの生成も止まります

データソースに「Amazon Managed Service for Prometheus」を選択し、以下のクエリを実行してみます。

sum(rate(traces_spanmetrics_calls_total[1m])) by (span_name)

sr-loki-tempo-fargate-06

GET /sql.conn.queryなど、スパン名ごとのレートが出ました。
エラーを流した時間帯だけGET /errorGET /failが立ち上がっています。

ちなみに、traces_spanmetrics_calls_totalをそのまま出すと単調増加のカウンタになり、「止まったこと」がグラフから読めません。
このあとの検証で前後比較するので、rate()で撮っておきます。

by (status_code)にすると、エラーの山がはっきり出ます。

sr-loki-tempo-fargate-10

Service graphの確認

TempoのQuery typeをService Graphにすると、サービス間の関係が見られます。
これを動かすには、Tempoデータソースの設定でService graphにAMPのデータソースを指定しておく必要があります。

なお、このService graphの設定は Additional settings を展開した中にあり、デフォルトでは折りたたまれています。

sr-loki-tempo-fargate-07

Rate / Error Rate / Duration(p90)がスパンごとに出ています。
GET /errorGET /failのError Rateが0.102です。

同じ画面のNode graphを開くとこうなります。

sr-loki-tempo-fargate-08

user → frontend → backend → Auroraの4ノードがつながりました。

トレース詳細でスパンをクリックして展開すると、スパン名の右に Related logs ボタンが出ます。

sr-loki-tempo-fargate-12

これを機能させるには、TempoデータソースのTrace to logsを設定しておく必要があります。

sr-loki-tempo-fargate-13

項目
Data source loki
Span start time shift -5m
Span end time shift 5m
Filter by trace ID OFF
Use custom query ON
Query {job="app"} |= "${__span.traceId}"

設定してから Related logs を押すと、Lokiに飛べます。

sr-loki-tempo-fargate-14

frontendとbackendが同じtrace_idで2行出ました。

ちなみに、データソース設定時のTrace to metricsにてAMPが出てきませんでした。
grafana-amazonprometheus-datasourceがcoreのPrometheus型ではないためです。

S3に何が置かれているのか

しばらく流していると、S3にオブジェクトが増えていきます。

Tempoは既定値のまま30〜60秒でS3に落ちました。

$ aws s3 ls s3://grafana-o11y-tempo-<アカウントID>/ --recursive
2026-09-10 10:50:00   57627  single-tenant/c38ea03b-.../data.parquet
2026-09-10 10:50:00      42  single-tenant/c38ea03b-.../index
2026-09-10 10:50:00     435  single-tenant/c38ea03b-.../meta.json
2026-09-10 10:50:35  102424  single-tenant/d6253ae4-.../bloom-0
2026-09-10 10:50:35   69612  single-tenant/d6253ae4-.../data.parquet

ブロックの間隔は35秒で、max_block_durationの既定30sどおりです。
1ブロックがdata.parquet / index / meta.json(+ bloom-0)の組で構成されています。

Lokiはchunkとindexを分けて置きます。

$ aws s3 ls s3://grafana-o11y-loki-<アカウントID>/ --recursive
2026-09-10 10:22:02    7937  fake/e2728b8663a305d9/1a088e533ae:1a088e84cfb:3f25a496
2026-09-10 10:25:05   14351  fake/e2728b8663a305d9/1a088e84f69:1a088eb18da:4b9e7df2
2026-09-10 11:19:21     440  index/index_20706/fake/...-compactor-...tsdb.gz

fake/配下がchunkです。
fakeauth_enabled: falseのときのテナントIDです。
index/配下のファイル名にcompactorが入っていて、compactorが作り直したものだとわかります。

chunkの間隔が約3分なのは、観察用にmax_chunk_ageを3分に縮めているためです。
既定の2時間のままだと、しばらく待っても何も落ちてきません。

Lokiのchunkは生ログではない

S3のchunkを1つ落として中を見てみます。

$ aws s3 cp s3://.../fake/e2728b8663a305d9/1a084ed391d:... ./loki-chunk.bin
$ file loki-chunk.bin
loki-chunk.bin: data

$ xxd loki-chunk.bin | head -5
00000000: 0000 00de ff06 0000 734e 6150 7059 01cc  ........sNaPpY..
00000010: 0000 9e90 7796 7b22 6669 6e67 6572 7072  ....w.{"fingerpr
00000020: 696e 7422 3a31 3633 3137 3235 3738 3039  int":16317257809

sNaPpYはSnappy圧縮のマジックバイトです。
stringsをかけてみると、先頭のメタデータだけが読めます。

$ strings loki-chunk.bin | head -3
sNaPpY
{"fingerprint":16317257809230235097,"userID":"fake","from":1788936534.301,"through":1788936537.813,
 "metric":{"__name__":"logs","container_name":"app","job":"app","service_name":"app"},"encoding":129}

ヘッダにラベルセットと時間範囲がJSONで入っていて、ログ本文はSnappyで圧縮されています。

Tempoのmeta.jsonにはreplicationFactor: 1が入っている

対照的に、Tempoのmeta.jsonはそのまま読めます。

$ aws s3 cp s3://.../single-tenant/9aebeec7-.../meta.json - | python3 -m json.tool
{
    "format": "vParquet4",
    "blockID": "9aebeec7-e169-4a38-976a-bb8c3e33aff8",
    "tenantID": "single-tenant",
    "startTime": "2026-09-10T06:48:54Z",
    "endTime": "2026-09-10T06:52:27Z",
    "totalObjects": 355,
    "compactionLevel": 2,
    "dedicatedColumns": [
        {"n": "http.request.method"}, {"n": "url.path"}, {"n": "url.route"}
    ],
    "replicationFactor": 1
}

Tempo 3.0では耐久性をKafkaが持つ設計になり、レプリケーションの仕組みそのものが無くなりました。

Because Kafka provides durability, Tempo 3.0 operates with a replication factor of 1 (RF1), eliminating the need for ingester replication.

https://grafana.com/docs/tempo/latest/set-up-for-tracing/setup-tempo/migrate-to-3/

検証1:Lokiのタスクを入れ替える

まず、通常のデプロイと同じようにタスクを入れ替えたときから確認します。

ここで大事なのは、未flushのデータがある状態で入れ替えることです。
S3に落ちきったデータで前後を比べても消えるわけがないので、何も証明できません。
トラフィックを流している最中に入れ替えます。

流し始めて5分ほど経ったところで、Exploreを10:40:0010:50:00に固定して撮ったのがこちらです。

sr-loki-tempo-fargate-15-phaseB-before

この状態でforce-new-deploymentをかけます。

$ aws ecs update-service --cluster grafana-o11y --service grafana-o11y-loki --force-new-deployment

新タスクが上がったあと、同じ10:40:0010:50:00で撮り直したのがこちらです。

sr-loki-tempo-fargate-16-phaseB-after

入れ替え前のログはそのまま残っていて、10:48:15〜10:49:20あたりに穴が空いています。

15秒刻みで数えてみると、以下のようになりました。

時刻 行数
10:48:00 46
10:48:15 48
10:48:30 6
10:48:45〜10:49:15 0
10:49:30 17
10:49:45 48

SIGTERMが飛んだのは10:48:17です。
その瞬間を含む10:48:15のバケットが満杯のままなので、Lokiが受け取り済みのログは1行も失われていません

Lokiのログを見ると、flushしている瞬間が残っていました。

$ aws logs tail /ecs/grafana-o11y/loki --since 10m | grep -iE 'flush|shutdown'
02:48:17 caller=manager.go:273  msg="stopping user managers"
02:48:17 caller=flush.go:305    msg="flushing stream" user=fake immediate=true
                                total_uncomp="163 kB" forced=1
02:48:17 caller=lifecycler.go   msg="lifecycler entering final sleep before shutdown"

immediate=true / forced=1がSIGTERM由来の強制flushです。
その1秒後にS3へ落ちています。

2026-09-10 10:48:18  11941  fake/e2728b8663a305d9/1a088fe03e8:1a089005950:2689ee66

この穴は「未flush分が消えた」ではありません。
穴の正体はLokiが起動していなかった約70秒で、その間Fluent Bitが送れなかった分です。
minimum_healthy_percent = 0にしているため新旧が同時に動かず、この空白が出ます。

消えなかったのは、flush_on_shutdown: trueにしているためです。
LokiはSIGTERMを受けると、終了する前にメモリ上のchunkをすべてS3へ書き出します。

検証2:Tempoを強制停止する

次に、Tempoのタスクをstop-taskで止めてみます。

$ TASK=$(aws ecs list-tasks --cluster grafana-o11y --service-name grafana-o11y-tempo \
    --query 'taskArns[0]' --output text)
$ aws ecs stop-task --cluster grafana-o11y --task $TASK --reason "verify ephemeral buffer loss"

停止が10:56:02、新しいタスクが上がったのが10:58:16で、約2分14秒止まっていました。

失ったのは30秒

Exploreの時間範囲を欠損の前後に絞り、Start timeの列をクリックして昇順にすると、飛んでいる箇所が見えます。

sr-loki-tempo-fargate-22-phaseC-tempo-gap

2026-09-10 10:55:42.818   ← 停止前の最後
2026-09-10 10:56:12       ← 復帰後の最初

欠損は30秒でした。
live_store.max_block_durationの既定30sと一致します。

つまりTempoは、どれだけ流し続けても失うのは直近1ブロック分で頭打ちになります。

同じ区間のX-Rayには残っている

時間範囲を欠損区間そのもの(10:55:4510:57:00)に絞ると、Tempoは空です。

sr-loki-tempo-fargate-25-phaseC-tempo-gap-empty

まったく同じ範囲でX-Rayを見てみると、こうなりました。

sr-loki-tempo-fargate-26-phaseC-xray-gap

242件そのまま残っています。
ADOTから二重送信していた分が、そのまま確認できました。

復帰の前後でトレースが分断される

これは想定していなかったのですが、復帰の前後で<root span not yet received>のトレースが断続的に出ました。

sr-loki-tempo-fargate-27-phaseC-broken-trace-1

区間 件数
10:56:12〜10:56:13 4
10:56:29〜10:56:33 16
10:56:54〜10:56:58 16

backendのスパンは届いているのに、frontendのルートスパンだけが落ちているトレースです。
frontendとbackendでADOTサイドカーが別々にリトライキューを持っているので、片方だけ取りこぼすとトレースが分断されます。
「失うのは30秒ぶんだけ」で終わらず、その前後でトレースが壊れていました。

AMPは3分半空いたが、それ以前は残っている

metrics-generatorはTempoと同じタスクに同居しているので、Tempoが止まればメトリクスの生成も止まります。
検証前と同じクエリで見てみます。

sr-loki-tempo-fargate-24-phaseC-amp-after

10:56:30〜11:00:00の約3分半が空きました。
Tempoの欠損(30秒)より大きくなっています。

これは、metrics-generatorがAMPへ送るときの仕組みによるものです。
生成したメトリクスはいったんタスク内のファイル(WAL)に書き、そこから順次AMPへ送信します。
このファイルはタスクのストレージにあるので、タスクが入れ替わると失われ、起動時に空の状態から作り直しになります。
その準備が終わるまで送信が始まらない分、復帰がトレースより遅れます。

ちなみに、この「Prometheus互換の保存先へメトリクスを送る」やり方がremote writeです。
AMPはPrometheus互換なので、この形で受け取れます。

ただし、それ以前の値はAMPに残っています。

ここで見ているのはRED metrics(Rate / Errors / Duration)と呼ばれる形の値で、
「1秒あたり何回呼ばれたか」「そのうち何回エラーだったか」「どれくらい時間がかかったか」を集計したものです。
個々のトレースが消えても、何時に何回呼ばれてどれくらいエラーが出ていたかという傾向は残るということになります。

検証3:Lokiをflushさせずに強制停止する

最後に、max_chunk_ageを既定の2時間に戻して止めてみます。

loki_chunk_idle_period = "3m"    # 既定は30m
loki_max_chunk_age     = "2h"    # 既定に戻す

ここで1つ問題があります。
stop-taskが送るのはSIGTERMなので、flush_on_shutdown: trueのままだと検証1と同じようにきれいにflushされてしまい、穴が空きません。

そこでflush_on_shutdownfalseにして、flushが間に合わずにタスクが落ちた状態を再現します。

ingester:
  wal:
    enabled: true
    dir: /loki/wal
    flush_on_shutdown: false   # flushさせずに落とす

トラフィックを流し始めて2分半ほど経った状態を、11:26:0011:38:00で撮ったのがこちらです。

sr-loki-tempo-fargate-29-phaseD-before

この時点でS3のchunkは増えていません。
max_chunk_ageが2時間で、しかも流し続けているのでidleにもならず、Lokiが持っているログは全部メモリの中にあります。

$ aws s3 ls s3://grafana-o11y-loki-<アカウントID>/ --recursive | tail -2
2026-09-10 11:09:16  14287  fake/e2728b8663a305d9/1a08910812c:...
2026-09-10 11:19:21    440  index/index_20706/...compactor...

この状態でstop-taskしました。
新タスクが上がってから、同じ11:26:0011:38:00で撮り直したのがこちらです。

sr-loki-tempo-fargate-30-phaseD-after

11:28:22〜11:32:20の約4分が丸ごと消えました。

時刻 行数
11:26:00〜11:32:00 すべて0
11:32:30 12
11:33:00 96

S3を見ても、停止の前後でchunkが1つも増えていません。

ログの差がわかりやすいです。

# 検証1(flush_on_shutdown: true)
02:48:17 msg="stopping user managers"
02:48:17 msg="flushing stream" immediate=true forced=1 total_uncomp="163 kB"
02:48:17 msg="lifecycler entering final sleep before shutdown"

# 検証3(flush_on_shutdown: false)
02:31:04 msg="stopping user managers"
02:31:04 msg="lifecycler entering final sleep before shutdown"      ← flushが無い

補足:冗長化について

今回はどちらも1タスクで動かしましたが、冗長化して失う量を減らせないかも見ておきます。

Lokiには、複数インスタンスをmemberlistでクラスタにしてreplication_factorを3にし、共有のオブジェクトストレージを見る「HA monolithic」という構成があります。

For high availability in monolithic mode, you can configure high availability by running two Loki instances using memberlist_config configuration and a shared object store and setting the replication_factor to 3.

simple scalable deployment(SSD)は4.0で削除される予定で、その移行先としてもHA monolithicが挙げられています。

Simple Scalable Deployment (SSD) mode is being deprecated and will be removed with the Loki 4.0 release.

https://grafana.com/docs/loki/latest/get-started/deployment-modes/

一方のTempoは、2.xにあったscalable monolithic(SSB)が3.0で削除され、monolithicとmicroservicesの2つになりました。

The scalable-single-binary target is no longer available in Tempo 3.0.

https://grafana.com/docs/tempo/latest/set-up-for-tracing/setup-tempo/migrate-to-3/

microservicesはKafkaが前提なので、AWSで組むならMSKなどを別途用意することになります。

最後に

今回は、Loki / TempoをECS Fargateに置いて、「ためてからS3へ」の挙動と、タスクが落ちたときにどれだけ失うのかを確認してみました。
この記事がどなたかの参考になれば幸いです。

この記事がどなたかの参考になれば幸いです。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事