
ECS FargateのLoki / Tempoが落ちたときに失うデータを検証してみた
こんにちは、ゲームソリューション部のsoraです。
今回は、ECS Fargateに置いたLoki / Tempoのタスクが落ちたときに、どれだけデータを失うのかを検証してみたことについて書いていきます。
以前ECS FargateでGrafanaをセルフホストしたときは、保存先をCloudWatch / X-Ray / AMPに寄せていました。
今回はその保存先をセルフホストのLoki / Tempoに置き換えます。
どちらもローカルにためてからS3に書くので、Fargateのエフェメラルなストレージだと、ためている最中に落ちた分は消えます。
今回は以下の点を検証しました。
- Loki / Tempoが「ためてからS3へ」書いていることを、S3のオブジェクトで確認すること
- タスクを強制停止したときに、TempoとLokiでそれぞれどれだけ失うか
構成
今回構築したのは以下の構成です。

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するまでの最大時間 |
なお、既定の2時間のままだとS3に落ちる様子を観察できないので、動作確認から検証2まではmax_chunk_ageを3分に縮めています。
既定に戻すのは検証3です。
Tempo
今回使ったTempoは3.0系です。
2026年8月に出た3.0で、内部のコンポーネントがingester / compactorからlive_store / block_builderに置き換わりました。
2.xの設定はそのままでは使えません。
Tempoにはデプロイモードが2つあり、今回は1プロセスで全部の役割を動かすmonolithicモードです。
もう一方のmicroservicesはKafkaが前提なので、1タスクで動かす今回の構成では選べません。
Tempoも、受け取ったスパンをいったんためて、ブロックという単位にまとめてからS3に書きます。
ためている役は、3.0で新しくなったlive_storeというコンポーネントです。
| パラメータ | 既定 | 役割 |
|---|---|---|
max_block_duration |
30s | ブロックを切るまでの最大時間 |
サイズで切る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が出ることを確認します。

Lokiでログ確認、Tempoでトレース確認
サンプルアプリには、正常に応答するエンドポイントとエラーを返すエンドポイントを用意しています。
これらに数分間アクセスして、ログ・トレース・メトリクスを溜めておきます。
Exploreでデータソースに「Loki」を選択し、{job="app"}でクエリを実行してみると、frontend / backendのアクセスログが確認できました。

10分間で1,129行入っています。
上部のログ量のヒストグラムが途切れなく並んでいるのが、このあと比較する「正常な状態」です。
データソースに「Tempo」を選択し、Query typeをSearchにして実行すると、トレースの一覧が表示されました。

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

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)

GET /やsql.conn.queryなど、スパン名ごとのレートが出ました。
エラーを流した時間帯だけGET /errorとGET /failが立ち上がっています。
ちなみに、traces_spanmetrics_calls_totalをそのまま出すと単調増加のカウンタになり、「止まったこと」がグラフから読めません。
このあとの検証で前後比較するので、rate()で撮っておきます。
by (status_code)にすると、エラーの山がはっきり出ます。

Service graphの確認
TempoのQuery typeをService Graphにすると、サービス間の関係が見られます。
これを動かすには、Tempoデータソースの設定でService graphにAMPのデータソースを指定しておく必要があります。
なお、このService graphの設定は Additional settings を展開した中にあり、デフォルトでは折りたたまれています。

Rate / Error Rate / Duration(p90)がスパンごとに出ています。
GET /errorとGET /failのError Rateが0.102です。
同じ画面のNode graphを開くとこうなります。

user → frontend → backend → Auroraの4ノードがつながりました。
Related logsの設定
トレース詳細でスパンをクリックして展開すると、スパン名の右に Related logs ボタンが出ます。

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

| 項目 | 値 |
|---|---|
| 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に飛べます。

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です。
fakeはauth_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.
検証1:Lokiのタスクを入れ替える
まず、通常のデプロイと同じようにタスクを入れ替えたときから確認します。
ここで大事なのは、未flushのデータがある状態で入れ替えることです。
S3に落ちきったデータで前後を比べても消えるわけがないので、何も証明できません。
トラフィックを流している最中に入れ替えます。
流し始めて5分ほど経ったところで、Exploreを10:40:00〜10:50:00に固定して撮ったのがこちらです。

この状態でforce-new-deploymentをかけます。
$ aws ecs update-service --cluster grafana-o11y --service grafana-o11y-loki --force-new-deployment
新タスクが上がったあと、同じ10:40:00〜10:50:00で撮り直したのがこちらです。

入れ替え前のログはそのまま残っていて、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の列をクリックして昇順にすると、飛んでいる箇所が見えます。

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:45〜10:57:00)に絞ると、Tempoは空です。

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

242件そのまま残っています。
ADOTから二重送信していた分が、そのまま確認できました。
復帰の前後でトレースが分断される
これは想定していなかったのですが、復帰の前後で<root span not yet received>のトレースが断続的に出ました。

| 区間 | 件数 |
|---|---|
| 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が止まればメトリクスの生成も止まります。
検証前と同じクエリで見てみます。

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_shutdownをfalseにして、flushが間に合わずにタスクが落ちた状態を再現します。
ingester:
wal:
enabled: true
dir: /loki/wal
flush_on_shutdown: false # flushさせずに落とす
トラフィックを流し始めて2分半ほど経った状態を、11:26:00〜11:38:00で撮ったのがこちらです。

この時点で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:00〜11:38:00で撮り直したのがこちらです。

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.
一方のTempoは、2.xにあったscalable monolithic(SSB)が3.0で削除され、monolithicとmicroservicesの2つになりました。
The scalable-single-binary target is no longer available in Tempo 3.0.
microservicesはKafkaが前提なので、AWSで組むならMSKなどを別途用意することになります。
最後に
今回は、Loki / TempoをECS Fargateに置いて、「ためてからS3へ」の挙動と、タスクが落ちたときにどれだけ失うのかを確認してみました。
この記事がどなたかの参考になれば幸いです。
この記事がどなたかの参考になれば幸いです。







