주말 장애를 분석해야 하는 상황이라 기본 retention 48시간에서 분석 대상 서비스의 tenant retention을 14일로 늘렸다. 그 PR을 반영한게 4월 6일 15:20분이었기 때문에 4월 4일 15:20 이후 trace부터 살아있을 거라고 생각하고 가정을 확인하고자 몇 시까지의 트레이스가 남아있는지 확인해봤다.
근데 실제로는 14:50대 trace가 살아있었다. 처음에는 아직 안지워진건가? 했는데, 끝까지 사라지지 않고 남아있었다.
이 글은 그 30분 차이를 이해하기 위해 작성했다. 결론부터 말하면, Tempo는 trace 개별 타임스탬프가 아니라 그 trace가 속한 block의 endTime 기준으로 retention을 판단한다. 이 한 줄에 도달하기까지의 과정이다.
배경
1. Tempo는 trace를 어떤 단위로 저장할까?
Grafana Tempo(이하 Tempo)는 분산 추적(distributed tracing) 백엔드다. 외부에서 들어온 trace는 바로 object storage에 쓰이지 않는다. ingester가 메모리의 head block에 먼저 쌓는다.
head block은 두 조건 중 하나를 만족하면 flush된다.
max_block_duration에 설정된 시간이 지났을 때max_block_bytes에 설정된 크기를 넘었을 때
flush가 끝나면 block 하나가 object storage에 저장된다. 각 block에는 meta.json이 함께 기록되는데, 여기에 startTime, endTime, compactionLevel 같은 메타데이터가 담긴다.
sequenceDiagram participant T as 외부 trace participant I as Ingester participant S as Object Storage T->>I: span 수신 I->>I: head block에 축적 Note over I: max_block_duration 또는<br>max_block_bytes 도달 I->>S: block flush<br>(compactionLevel=0) Note over S: meta.json:<br>startTime, endTime 기록
block 하나에는 여러 trace가 들어간다. startTime과 endTime은 그 block 안에 담긴 trace들의 시각 범위다. 이 범위가 retention 계산 기준이 된다.
2. Compactor는 무엇을 하는가?
Compactor는 두 가지 일을 한다.
작은 block들을 합쳐 큰 block으로 만든다. ingester가 flush할 때마다 작은 block이 하나씩 생긴다. compactor는 compaction_window(기본 1시간) 범위 안의 block들을 병합해서 더 큰 block으로 만든다. 병합이 거듭될수록 compactionLevel이 올라간다.
만료된 block을 삭제한다. 기본 30초 주기(compaction_cycle)마다 돌면서 retention이 지난 block을 marking block for deletion → deleting block 순서로 처리한다.
flowchart TD A["ingester flush block<br>(compactionLevel=0, 크기 ~20MB)"] B["compacted block<br>(compactionLevel=1~2)"] C["compacted block<br>(compactionLevel=3)"] D["marking for deletion → 삭제"] A -->|"compactor 병합"| B B -->|"compactor 병합"| C C -->|"retention 만료 판단"| D
3. Retention은 어떤 기준으로 동작할까?
직관적으로는 “trace의 타임스탬프가 retention 기간보다 오래됐으면 삭제”처럼 보인다. 나도 그렇게 생각했는데, 실제로는 block의 endTime 기준으로 판단한다.
tempodb/retention.go를 보자.
// tempodb/retention.go
cutoff := time.Now().Add(-retention)
for _, b := range blocklist {
if b.EndTime.Before(cutoff) && compactorSharder.Owns(b.BlockID.String()) {
// marking block for deletion
}
}cutoff = 현재 시각 - retention 기간을 계산하고, b.EndTime < cutoff이면 삭제 대상이다. 개별 trace 타임스탬프는 여기 등장하지 않는다.
그렇다면 block.EndTime은 정확히 뭘까? → tempodb/backend/block_meta.go에 있다.
// tempodb/backend/block_meta.go
func (b *BlockMeta) ObjectAdded(start, end uint32) {
endTime := time.Unix(int64(end), 0)
if b.EndTime.IsZero() || endTime.After(b.EndTime) {
b.EndTime = endTime // block 내 trace 중 가장 큰 endTime으로 갱신
}
}block에 trace가 추가될 때마다 ObjectAdded가 호출되고, EndTime은 그 중 가장 큰 값으로 계속 갱신된다. 이 end 값은 modules/distributor/distributor.go에서 span 단위로 추출한다.
// modules/distributor/distributor.go
func startEndFromSpan(span *v1.Span) (uint32, uint32) {
return uint32(span.StartTimeUnixNano / uint64(time.Second)),
uint32(span.EndTimeUnixNano / uint64(time.Second))
}즉 block.EndTime은 그 block 안에 있는 모든 span의 EndTimeUnixNano 중 가장 큰 값이다.
trace가 오래된 시각에 생성됐더라도, 그 trace가 포함된 block의 endTime이 아직 retention 안에 있으면 삭제되지 않는다.
tenant retention 설정 반영
먼저 확인한 건 기초적인 것들이었다.
- 이 서비스가 Tempo에서 쓰는 tenant ID는?
- 현재 retention은 얼마인가?
- 어떤 파일을 수정하면 되는가?
- merge 후 자동 배포인가, 수동 sync인가?
확인 결과, 이 서비스(이하 ServiceA)는 per-tenant override가 없어서 global default 48h를 쓰고 있었다. ArgoCD의 tempo Application은 auto-sync가 아니어서 merge 후 수동 sync가 필요했다.
tempo-values.yaml에 다음 블록을 추가했다.
ServiceA:
compaction:
block_retention: 336h
ingestion:
burst_size_bytes: 50000000
max_traces_per_user: 55000
rate_limit_bytes: 70000000
metrics_generator:
processors:
- service-graphs
- span-metricsblock_retention: 336h는 14일이다. Tempo의 per-tenant override 파일은 동적으로 로드되기 때문에 재시작 없이 반영된다.
단, “YAML을 수정했다”와 “live에 반영됐다”는 다른 말이다. Git repo 파일이 바뀐 것만으로는 실제 동작을 보장할 수 없다. Git → ArgoCD → ConfigMap → running pod로 이어지는 전달 체인이 실제로 닫혀야 한다.
flowchart LR A["GitHub PR merge"] --> B["ArgoCD<br>live revision"] B --> C["tempo-runtime<br>ConfigMap"] C --> D["pod mounted<br>overrides.yaml"]
그래서 merge 전에 baseline을 먼저 기록했다. 당시 tempo-runtime ConfigMap에는 ServiceA 블록이 없었다. 비교를 위해 per-tenant override가 있는 ServiceB, live traffic은 있지만 override가 없는 ServiceC도 함께 확인했다. “override가 있는 tenant는 runtime config에 이름이 나타난다”는 기준 사례가 필요했다.
PR merge 후 ArgoCD sync를 완료하고 다시 확인했다.
- PR merge commit 존재 ✅
- ArgoCD live revision이 새 commit을 가리킴 ✅
tempo-runtimeConfigMap에 ServiceA 블록 존재 ✅- running pod의
/runtime-config/overrides.yaml에 동일한 값 존재 ✅
trace retention 14일 설정이 live에 반영됐다.
가설과 검증
1단계: 실제로 어디까지 trace가 살아있는지 확인하기
설정이 반영됐으니 바로 boundary를 직접 확인해보고 싶었다. 4월 6일 15:20에 14일 retention을 적용했으니 역산하면 4월 4일 15:20이 경계일 거라 생각했는데, 설정으로 유추한 것과 실제 데이터가 같은지 직접 보고 싶었다.
Tempo search API를 조회했다.
| 시간 범위 (KST) | 조회 결과 |
|---|---|
| 4월 4일 14:49~14:50 | trace 없음 |
| 4월 4일 14:50~14:51 | trace 존재 |
| 4월 4일 15:10~15:20 | trace 존재 |
| 4월 4일 15:20~15:30 | trace 존재 |
| 4월 4일 15:40~15:50 | trace 존재 |
경계가 예상한 15:20이 아니라 14:50 KST 전후였다. 왜 하필 14:50이었을까?
2단계: 단순 삭제 지연일까?
처음 떠올린 가설은 단순했다. “compactor에 주기와 지연이 있으니, 48시간이 지나도 삭제가 바로 안 된 거 아닐까?” compaction_cycle, blocklist_poll 같은 지연 요인이 실제로 존재하긴 한다.
근데 이 설명이 맞으려면 boundary가 15:20 근처에서 흔들려야 한다. 실제 boundary는 15:20이 아니라 14:50 전후였고, 경계가 꽤 명확했다. 30분 차이를 지연으로 설명하기엔 뭔가 맞지 않았다.
그렇다면 “조금 늦게 지워지는 것”이 아닐 수 있다. retention 판단 기준 자체가 다를 수 있다는 뜻이다. 앞서 봤듯이 Tempo는 trace를 개별로 저장하지 않는다. ingester가 모아서 block으로 만들고, compactor가 그걸 다시 병합한다. 삭제 단위도 trace가 아니라 block이라면 어떨까?
그 block의 endTime이 15:20 이후까지 걸쳐 있다면, compactor 입장에선 아직 retention 기간 내의 block이고 — 그래서 그 안에 있는 14:50 trace도 같이 살아있는 게 된다.
“14:50 trace가 담긴 block의 endTime이 15:20 이후라면, 그 block 전체가 살아있다.”
이걸 확인하려면 search API로는 부족했다. search는 “있다/없다”만 알려줄 뿐, 어떤 block에 들어 있는지는 모른다. object storage의 block metadata를 직접 봐야 했다.
3단계: 14:50 trace는 어떤 block에 있을까?
14:50대 trace가 포함된 block의 endTime이 15:20을 넘는지 확인해야 했다. object storage의 각 block 경로에는 meta.json이 있고, 여기에 startTime, endTime, compactionLevel 같은 메타데이터가 담긴다. 이 파일을 직접 읽었다.
4월 4일 14:50:39 KST trace를 포함하는 block이다.
{
"blockID": "62ea3102-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"startTime": "2026-04-04T05:50:37Z",
"endTime": "2026-04-04T06:39:29Z",
"size": 610935607,
"compactionLevel": 3
}KST로 변환하면 이 block은 14:50:37 ~ 15:39:29를 덮는다. 4월 6일 15:20 기준 역산 시 endTime이 4월 4일 15:20 이후인 block은 아직 14일 retention 기간 안에 있다. 이 block의 endTime은 15:39:29이니 삭제 대상이 아니다.
반대로, 14:49:30 KST를 포함하는 block은 찾을 수 없었다. 이미 삭제된 상태였다.
timeline title 4월 4일 KST - 관찰된 경계 14-49-30 : 삭제된 block : 더 이상 존재하지 않음 14-50-37 : surviving block 시작 15-20-00 : retention override 적용 시각의 역산 기준점 15-39-29 : surviving block endTime
가설이 맞았다. 14:50 trace가 살아있던 이유는 그 trace의 시각 때문이 아니라, 15:39까지 끝나는 block 안에 들어 있었기 때문이었다.
4단계: 살아있는 block이 새 retention 경로로 넘어갔나?
“지금 보인다”는 것과 “앞으로도 14일 동안 살아있다”는 건 다른 말이다. 이게 이번 작업의 실질적인 목적이었다.
살아있는 block들이 이미 old 48h 경계를 넘겼는데도 삭제 경로에 안 올라가고 있다면, 새 retention 기준으로 판단받고 있는 거다. compactor 로그를 확인했다.
- block A endTime: 4월 4일 15:39:29 KST
- block B endTime: 4월 4일 15:59:48 KST
확인 시점 기준으로 두 block 모두 48시간을 넘긴 상태였다. old retention이 그대로였다면 이미 삭제됐어야 한다.
그런데 compactor 로그를 보면, 같은 시간대 다른 block들은 marking block for deletion, deleting block으로 처리되고 있었다. 두 block ID는 그 경로에 없었다.
compactor가 안 돌고 있는 게 아니라, 같은 tenant 안에서도 어떤 block은 지우고 어떤 block은 남기고 있었다.
살아있는 block들은 이미 새 retention 기준으로 판단받고 있었다. 주말 데이터 보존이라는 원래 목적은 달성된 셈이었다.
정리
14:50이 경계가 된 이유를 설명하면 다음과 같다.
flowchart TD A["ingester: 10분 시간 기반 block cut<br>(max_block_duration=10m, size ~20MB)"] B["compactor: compaction_window 1h 범위로<br>여러 block 병합 (compactionLevel 0→3)"] C["surviving block<br>startTime 14:50:37 / endTime 15:39:29 KST"] D["compactor: block endTime 기준으로 retention 만료 판단"] E["endTime 15:39 > retention 역산 기준 15:20<br>→ 삭제 대상 아님"] A --> B B --> C C --> D D --> E
ingester는 max_block_duration: 10m 기준으로 block을 flush했다. 로그 확인 결과 block cut size가 16~33MB 수준이었다. 64MiB threshold와 거리가 있으니 용량이 아니라 시간 기준으로 잘린 tenant였다.
compactor는 이 10분짜리 block들을 compaction_window 1시간 범위 안에서 병합했다. 결과로 14:50:37에 시작해 15:39:29에 끝나는 block이 만들어졌다(compactionLevel=3). 이 block에 14:50대 trace와 그 뒤 여러 시각의 trace가 모두 들어갔다.
compactor는 retention 판단 시 block의 endTime을 기준으로 삼는다(b.EndTime.Before(cutoff)). 이 block의 endTime은 15:39:29이고, 역산 기준인 15:20보다 이후이니 삭제하지 않았다.
“15:20에 바꿨으니 15:20 이전 데이터는 없어야 한다”는 직관은, block의 endTime이 15:20 이후까지 걸쳐 있는 경우를 고려하지 않은 것이었다.
참고 자료
- Grafana Tempo Configuration
- Grafana Tempo Multitenancy — per-tenant overrides
- Grafana Tempo Polling — blocklist staleness and compacted block retention
- Grafana Loki Log Retention
- Tempo 소스 코드 — tempodb/retention.go
- Tempo 소스 코드 — tempodb/backend/block_meta.go
- Tempo 소스 코드 — modules/distributor/distributor.go