나리
전시글만드는 방식문의
←블로그 목록
개발 일지2026-10-08LevelUp Piano — MIDI 피아노 연습 앱

exit 137 — 관측 도구가 서비스를 죽인 날

트레이싱을 붙이고 다섯 주 뒤, 1GB 머신이 OOM으로 죽었습니다. 메모리를 2GB로 올려 급한 불을 끄고, 한 달 뒤 숫자를 재서 범인(OTel 에이전트)을 빼고 1GB로 돌아왔습니다. 관측 스택의 비용을 메모리로 치른 기록입니다.

운영사고 기록옵저버빌리티Fly.io
137exit code (OOM kill)
1GB → 2GB → 1GBVM 메모리
~0.2GBOTel 에이전트 몫
~0.5GB제거 후 RSS

5월, 트레이싱을 붙였다

관측 스택을 단계적으로 쌓던 때였습니다. Grafana Alloy 사이드카로 메트릭을 Grafana Cloud에 보내고, 로그를 Loki로 보내고, 마지막으로 OpenTelemetry Java 에이전트를 붙여 Ktor → DB → Tempo 트레이스를 뽑았습니다. 점수 제출처럼 DB 쓰기와 외부 저장소 접근이 섞인 요청에서 병목 구간을 보고 싶었습니다.

붙이는 데 코드 변경은 0줄이었습니다. 자동 계측이라 에이전트 하나 얹으면 끝이었습니다. 비용은 의식하고 있었습니다. 샘플링을 10%로 잡아 Tempo 무료 한도 50GB/월을 넘지 않도록 계산해 두었으니까요. 요금은 계산했고, 메모리는 계산하지 않았습니다.

6월 27일에 쓴 관측·배포 글에는 이렇게 적혀 있습니다. "관측 스택은 공짜가 아닙니다. 에이전트도 메모리를 먹습니다." 알고는 있었습니다. 숫자로는 몰랐습니다.

7월 5일, exit 137

앱이 얼었습니다. Apple 로그인을 포함해 모든 요청이 무응답이었습니다. 종료 코드는 137. OOM 킬러가 프로세스를 죽인 것입니다.

머신은 1GB였습니다. 그 안에 든 것:

항목메모리
JVM 힙512MB
메타스페이스128MB
JVM 부가(스레드 스택, 코드 캐시, 네이티브)?
Grafana Alloy 사이드카?
OTel Java 에이전트?

물음표가 세 개였습니다. 합이 1GB를 넘었다는 것만 확실했습니다.

🐞 The bug

JVM 힙을 512MB로 제한해 두면 "메모리는 512MB"라고 착각하기 쉽습니다. 힙은 JVM 메모리의 일부이고, JVM은 컨테이너 메모리의 일부입니다. 사이드카와 에이전트는 그 바깥에 삽니다.

급한 불 — 2GB

그날 한 일은 하나였습니다.

fly scale memory 2048

그리고 fly.toml에도 반영해 다음 배포에서 되돌아가지 않게 했습니다. 원인을 정확히 모르는 상태에서 할 수 있는 가장 안전한 조치였고, 비용은 월 약 $3.5였습니다. 원인 분석은 서비스가 살아난 뒤에 해도 됩니다.

8월 7일, 숫자를 재고 되돌리다

한 달 뒤 비용을 정리하면서 물음표를 채웠습니다. 실측으로 OTel 에이전트가 약 0.2GB를 차지했고, 그게 1GB를 넘긴 주범이었습니다.

그래서 물었습니다. 트레이스로 실제로 무엇을 했나. 답은 "병목을 찾을 때 p95와 쿼리 로그로 충분했다"였습니다. 분산 시스템이 아니라 머신 1대, DB 1대입니다. 요청 하나가 거치는 구간은 Ktor 핸들러, 트랜잭션, R2 presign 셋뿐이고, 어디가 느린지는 메트릭의 p95와 로그의 requestId로 좁혀집니다.

트레이스를 뺐습니다.

  • Dockerfile: javaagent 다운로드와 OTEL_* 환경변수 삭제
  • config.alloy: otelcol receiver·processor·exporter 블록 삭제. 같은 김에 loki.process에 stage.drop을 넣어 DEBUG·INFO·TRACE 로그는 전송하지 않도록 바꿨습니다. 로그 전송량도 비용입니다.
  • entrypoint.sh: Alloy 기동 게이트에서 OTLP 조건 제거, -javaagent 미부착
  • fly.toml: 2GB → 1GB

제거 후 부팅 직후 RSS는 약 0.5GB, 현실 상한 추정은 0.7GB. 1GB면 헤드룸이 충분합니다. fly.toml에는 이렇게 남겼습니다.

[[vm]]
  size = 'shared-cpu-1x'
  # 1GB — OTel 에이전트 제거 후 실측 RSS ~0.5GB(부팅 직후), 현실 상한 ~0.7GB.
  # 이전 1GB OOM(exit 137, 2026-07-05)은 OTel 에이전트(+~0.2GB)가 주범이었음.
  # exit 137 재발 시 즉시 'fly scale memory 1536' (또는 2048) 로 롤백.
  memory = '1gb'

사고 이력과 롤백 명령을 설정 파일 옆에 적어 두는 것, 혼자 운영할 때 가장 값싼 보험이었습니다.

재발 감시

8월 26일, ResourceWatchdog에 RSS 감시를 넣었습니다. 5분마다 RSS를 읽어 0.8GB를 넘으면 Telegram으로 알립니다. 0.8은 실측 상한 0.7과 머신 1GB 사이의 숫자입니다. 알림이 오면 fly scale memory를 판단합니다. 죽은 뒤 exit 137을 보는 게 아니라, 죽기 전에 봅니다.

배운 것

💡 Insight

관측 도구의 비용은 청구서보다 먼저 서비스의 자원으로 나옵니다. "보이게 만드는 도구가 서비스를 방해하지 않게"는 원칙이 아니라 측정해야 하는 숫자였습니다.

세 가지를 규칙으로 남겼습니다.

  1. 컨테이너 메모리는 JVM 힙이 아니라 힙 + 메타스페이스 + 부가 + 사이드카 + 에이전트로 계산한다.
  2. 관측 신호 하나를 붙일 때마다 "이걸로 지난달 무엇을 결정했나"를 묻는다. 답이 없으면 뺀다. 트레이스는 답이 없었고, 메트릭과 로그는 있었다.
  3. 사고가 났던 설정값 옆에는 사고 날짜, 실측값, 롤백 명령을 주석으로 둔다.

트레이스가 필요해지는 날은 올 수 있습니다. 머신이 2대가 되고 서비스가 쪼개지면 그때 다시 붙입니다. 그때는 메모리를 먼저 잴 겁니다.

137exit code (OOM kill)
1GB → 2GB → 1GBVM memory
~0.2GBtaken by the OTel agent
~0.5GBRSS after removal

May: tracing goes in

I was building the observability stack up in stages. A Grafana Alloy sidecar shipped metrics to Grafana Cloud, logs went to Loki, and finally the OpenTelemetry Java agent produced Ktor → DB → Tempo traces. I wanted to see where time went in requests like score submission, which mix a database write with object-storage access.

Adding it took zero lines of code change. Auto-instrumentation: drop in the agent and you are done. I was aware of cost, too. Sampling was set to 10% so that Tempo's 50 GB/month free allowance would hold. I had calculated the bill. I had not calculated the memory.

The observability and deploys post from June 27 says: "An observability stack isn't free; agents eat memory too." I knew that. I did not know it as a number.

July 5: exit 137

The app froze. Every request, Apple sign-in included, stopped responding. The exit code was 137. The OOM killer had taken the process.

The machine had 1GB. Inside it:

ItemMemory
JVM heap512MB
Metaspace128MB
JVM overhead (thread stacks, code cache, native)?
Grafana Alloy sidecar?
OTel Java agent?

Three question marks. The only certainty was that the sum exceeded 1GB.

🐞 The bug

Capping the JVM heap at 512MB makes it easy to believe "memory is 512MB." The heap is one part of JVM memory, and the JVM is one part of the container's memory. Sidecars and agents live outside both.

Stopping the bleeding: 2GB

I did exactly one thing that day:

fly scale memory 2048

And mirrored it in fly.toml so the next deploy would not undo it. It was the safest move available without a confirmed root cause, and it cost about $3.50 a month. Root-cause analysis can wait until the service is back.

August 7: measure, then roll back

A month later, while trimming costs, I filled in the question marks. Measured, the OTel agent accounted for roughly 0.2GB, and that was what pushed us over 1GB.

So I asked: what had the traces actually done for me? The answer was "p95 and query logs were enough to find bottlenecks." This is not a distributed system. One machine, one database. A request passes through three segments, the Ktor handler, the transaction, and the R2 presign, and the slow one is narrowed down by the p95 metric and the log's requestId.

The traces came out.

  • Dockerfile: removed the javaagent download and the OTEL_* environment variables
  • config.alloy: removed the otelcol receiver, processor and exporter blocks. While there, added a stage.drop to loki.process so DEBUG, INFO and TRACE logs are never shipped. Log volume is a cost too.
  • entrypoint.sh: dropped the OTLP condition from the Alloy startup gate, no more -javaagent
  • fly.toml: 2GB → 1GB

After removal, RSS right after boot was about 0.5GB, with a realistic ceiling estimated at 0.7GB. 1GB leaves enough headroom. fly.toml now reads:

[[vm]]
  size = 'shared-cpu-1x'
  # 1GB — measured RSS ~0.5GB right after boot with the OTel agent removed;
  #   realistic ceiling ~0.7GB. The earlier 1GB OOM (exit 137, 2026-07-05) was
  #   mainly the OTel agent (+~0.2GB). If exit 137 recurs, roll back at once
  #   with 'fly scale memory 1536' (or 2048).
  memory = '1gb'

Writing the incident history and the rollback command right next to the setting is the cheapest insurance a solo operator can buy.

Watching for it

On August 26 I added RSS monitoring to ResourceWatchdog. Every five minutes it reads RSS and alerts Telegram above 0.8GB, a number sitting between the measured 0.7GB ceiling and the 1GB machine. When the alert fires, I decide on fly scale memory. I get to see it before the process dies, instead of reading exit 137 afterwards.

What I learned

💡 Insight

The cost of observability shows up as the service's own resources before it shows up on a bill. "Tools that make things visible must not get in the service's way" was not a principle. It was a number I had to measure.

Three rules came out of it.

  1. Container memory is not the JVM heap. It is heap + metaspace + JVM overhead + sidecars + agents.
  2. For every observability signal, ask "what decision did this drive last month?" No answer, it goes. Traces had no answer. Metrics and logs did.
  3. Next to any setting that caused an incident, leave the date, the measured values, and the rollback command as a comment.

The day traces become necessary may come. When there are two machines and the service splits, they go back in. That time, I will measure memory first.

관람해 주셔서 고맙습니다

© 2026 나리인포메이션(Nari Information). All rights reserved.

사업자등록번호 746-08-03415 · hello@nariinfo.dev

LevelUp PianoMazzucchiVault