
Codex가 연간 640TB를 SSD에 쓰고 있었던 원인, TRACE 로그를 추적하다
요약
OpenAI Codex CLI의 로그 메커니즘 결함으로 인해 SSD에 과도한 쓰기 작업이 발생하여 하드웨어 수명을 단축시키는 문제가 발견되었습니다. SQLite 로그 파일의 크기는 일정하게 유지되지만, 내부적으로 방대한 양의 데이터가 삽입 및 삭제를 반복하며 심각한 쓰기 증폭을 유발합니다.
핵심 포인트
- Codex CLI의 SQLite 로그가 연간 약 640TB의 쓰기량을 발생시킴
- 파일 크기는 유지되나 내부적인 삽입/삭제 반복으로 쓰기 증폭 발생
- 로그 레벨이 TRACE로 기본 설정되어 WebSocket 페이로드 등을 과도하게 기록
- 사용자가 인지하기 어려운 '정숙한' 하드웨어 손상 위험
내 손안의 SSD가 키 입력을 하지 않는 시간대에도 일정하게 깎여 나가고 있다. 프로세스를 추적해 보니 범인은 에디터도 브라우저도 아닌, 상주시켜 두었던 OpenAI Codex CLI의 로그 메커니즘이었다. 한 사용자의 측정에 따르면, 약 21일간의 연속 가동으로 SSD 쓰기량이 약 37TB에 달했으며, 그 대부분을 Codex의 SQLite 로그가 차지하고 있었다. 연간 환산하면 약 640TB. 1TB SSD라면 1년 동안 약 640회, 드라이브 전체를 덮어쓰며 수명을 다하게 되는 계산이다.
이 이야기가 무서운 점은 버그 그 자체보다 '정숙함'이다. 에러도 경고도 나오지 않은 채, 소모품인 NAND 플래시의 수명만이 깎여 나간다. 보고 내용은 openai/codex의 Issue #28224에 정리되어 있으며, 게시자(Rui Fan 씨)는 실측 데이터와 함께 원인까지 분리해 내었다. 다음은 그 1차 정보를 추적한 내용이다.
Codex는 조작 피드백용 로그를 홈 디렉토리의 SQLite 파일에 계속해서 기록한다. 실체는 다음 3개의 파일이다.
~/.codex/logs_2.sqlite
~/.codex/logs_2.sqlite-wal
~/.codex/logs_2.sqlite-shm
흥미로운 점은 파일 크기가 폭주하지 않는다는 것이다. DB는 오래된 행을 삭제(pruning)하며 운용되기 때문에, 겉보기에는 1GB 전후로 안정되어 보인다. 하지만 SQLite의 AUTOINCREMENT 카운터를 살펴보면, 유지하고 있는 행의 수와 누적 채번 수 사이에 10,000배 가까운 차이가 있다.
| 지표 | 값 |
|---|---|
| 현재 파일 크기 | 1.2 GiB |
| ... |
즉, 파일 크기는 늘어나지 않지만, 내부에서는 방대한 양의 행이 '삽입되었다가 삭제되는' 과정을 반복하고 있다. 게시자가 15초간 샘플링한 결과, 유지 행수는 변하지 않은 채 36,211행이 삽입되어 있었다. 삽입, 인덱스 업데이트, WAL(Write-Ahead Logging)로의 쓰기, 삭제라는 일련의 처리가 매번 스토리지에 대한 물리적 쓰기(write amplification, 쓰기 증폭)를 발생시킨다. 파일 모니터링 도구로 크기만 보고 있어서는 절대 알아챌 수 없는 종류의 비용이다.
그렇다면 무엇이 그렇게 많이 기록되고 있는 것일까. 유지되는 바이트를 로그 레벨(log level)별로 나누어 보니 편차가 노골적이었다.
| 레벨 | 유지 바이트 비율 |
|---|---|
| TRACE | 70.7% |
| ... |
TRACE는 본래 개발자가 버그를 추적할 때만 활성화하는 가장 상세한 레벨이다. 그것이 상시 디스크에 기록되고 있었다. 내역 중 가장 큰 비중을 차지하는 것은 codex_api::endpoint::responses_websocket의 TRACE로 527.4 MiB였다. API와의 WebSocket 이벤트 생(raw) 페이로드를 하나도 빠짐없이 기록하고 있었다. 여기에 더해 codex_otel.log_only와 codex_otel.trace_safe라는 OpenTelemetry의 미러 출력이 이중으로 기록되어, 이것만으로 25%를 넘는다. 요컨대 텔레메트리(telemetry)의 초안을 로컬 SSD에 전문(full text)으로 계속 저장하고 있었던 것이다.
근본 원인은 싱크(sink, 로그 출력 대상)의 초기화에 있었다. SQLite 로그용 필터가 기본값을 통째로 TRACE로 설정하고 있었던 것이다.
Targets::new().with_default(Level::TRACE)
이 한 줄이 결정적인 포인트인데, 콘솔 표시의 상세도를 어떻게 조절하더라도 SQLite 싱크는 자체 기본값인 TRACE로 모든 타겟을 수집해 버린다. 의존 라이브러리의 내부 로그(tokio-tungstenite의 WebSocket 처리나, ld.so.cache를 열었다는 inotify 이벤트 등)까지 무차별적으로 영속화되고 있었다. 로그 레벨 설계를 한 곳에서 잘못하면 통신량이 아닌 스토리지 수명이라는 예상치 못한 형태로 되돌아온다는 좋은 사례라고 생각한다.
당장 멈추고 싶다면 Issue 내에서 공유되고 있는 회피책이 확실하다. Codex를 종료한 후, logs 테이블에 대한 INSERT를 트리거(trigger)로 무효화하는 것이다.
sqlite3 ~/.codex/logs_2.sqlite "CREATE TRIGGER IF NOT EXISTS \"block_log_inserts BEFORE INSERT ON logs BEGIN SELECT RAISE(IGNORE); END;"
공식 측도 6월 중에 움직였다. 수정 사항은 3개의 PR(Pull Request)로 나뉘어 있다.
| PR | 내용 | 반영 버전 |
|---|---|---|
| #29432 | 성공한 WebSocket 이벤트의 TRACE 기록 및 OpenTelemetry 로그/트레이스 발행 중단 | 0.142.0 |
| #29457 | target=log 및 codex_otel 미러를 SQLite 싱크(Sink)에서 제외 | 0.142.0 |
| #29599 | 브릿지(Bridge)를 통해 누출되던 의존성 로그를 싱크 내에서 추가 거부 | 0.143.0 |
게시자의 재측정에 따르면, 이를 통해 로그 양의 약 85%가 절감되었다. 카운터(Counter)나 소요 시간 등의 메트릭(Metrics), 원격지로의 OpenTelemetry 내보내기(Export)는 유지하면서, 로컬로의 생 페이로드(Raw payload) 저장만 제외하는 방침으로, 해결 방법으로서도 타당하다.
현재 로컬 SQLite 로그 싱크는 모든 타겟에 대해 TRACE를 활성화하고 있다. (PR #29457에서 발췌)
이 사건을 Codex 고유의 실수로 치부하기에는 아쉬운 점이 많다. 에이전트형 도구는 "나중에 동작을 재현하거나 분석할 수 있도록" 관측 로그(Observability log)를 풍부하게 남기려 하지만, 그 저장소가 개발자의 물리 드라이브가 되는 순간, 상세도(Verbosity)의 기본값은 수명 비용과 직결된다. SSD의 TBW(총 쓰기 가능 용량)는 소모품의 잔량 그 자체이며, 이는 조용히 줄어든다.
실무자로서 가져가야 할 교훈은 두 가지가 있다. 하나는, 상주하는 개발 도구를 설치했다면 ~/.cache나 ~/.local 하위에 비대해진 로그 DB가 없는지 한 번 확인해 두는 것이다. 다른 하나는, 본인이 도구를 만드는 입장이라면 텔레메트리(Telemetry)의 기본값을 TRACE가 아닌 INFO 이상으로 설정하고, 생 페이로드의 영속화(Persistence)는 옵트인(Opt-in) 방식으로 만드는 것이다. 관측성(Observability)은 정의롭지만, 그 대가를 사용자에게 말도 없이 하드웨어로 청구해서는 안 된다. 버전을 0.142.0 이후로 올렸는지 여부는 오늘 중으로 확인해 두어도 손해 볼 것이 없다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기