목차
- Before/After: 80GB 디스크에서 1GB 캡까지
- 문제 정의 — json-file 드라이버의 기본 동작이 위험한 이유
- 기존 접근의 한계 — logrotate, cron 삭제, 수동 truncate
- daemon.json으로 호스트 전역 기본값 설정
- docker-compose.yml의 logging 옵션 — 서비스별 세분화
- 검증 — 부하 테스트로 확인한 로그 유실 패턴
- json-file의 한계와 대안 — journald, fluentd, awslogs
- 한계점 정리와 당장 할 수 있는 액션
Before/After: 80GB 디스크에서 1GB 캡까지
예를 들어, docker 로그 설정 드라이버를 손대지 않은 상태로 운영 서버를 6개월 굴렸더니, /var/lib/docker/containers 아래 누적된 json 로그가 80GB를 넘겼다. 호스트 디스크가 가득 차면서 컨테이너 절반이 일제히 멈췄고, 디스크 알람이 새벽에 뜨면서 일이 커졌다.
이처럼, 설정을 잡고 나서는 컨테이너 하나당 로그 점유량이 최대 1GB(=100MB × 10개) 안에서만 움직인다. 컨테이너 50개를 굴려도 50GB를 넘지 않는다. 그 뒤로 디스크 풀 사고는 없었다.
| 항목 | Before | After |
|---|---|---|
| 컨테이너당 로그 점유 | 무제한 | 100MB × 10개 = 1GB |
| 회전 정책 | 없음 | 자동 (gzip 압축) |
| 디스크 풀 사고 | 6개월에 2회 | 0회 |
| 회전 시 로그 드랍 | 거의 없음 | 미세하게 발생 |
실제로, After 쪽이 회전 순간의 로그 유실 위험은 오히려 살짝 올라간다. 무한 저장이 안전하다는 뜻은 아니고, 한계를 알고서 외부 수집기와 같이 써야 한다는 뜻이다. 이 글은 그 한계까지 같이 정리한 결과물이다.
문제 정의 — json-file 드라이버의 기본 동작이 위험한 이유
Docker 데몬은 별다른 지정이 없으면 json-file 드라이버를 쓴다. 컨테이너의 stdout과 stderr를 호스트의 /var/lib/docker/containers/[container-id]/[container-id]-json.log 경로에 한 줄당 JSON 한 객체로 적는다. 이 동작은 공식 문서 Configure logging drivers에 명시되어 있고, 2026년 6월 기준 LTS인 Docker Engine 24.x, 25.x에서 동일하다.
문제는 이 드라이버의 max-size와 max-file 옵션 기본값이 unlimited라는 점이다. 즉 컨테이너가 살아 있는 동안 로그 파일은 무한히 자란다. 한 줄당 200바이트짜리 access log를 초당 100건씩만 찍어도 하루 1.7GB가 쌓인다. 마이크로서비스 30개가 비슷한 페이스로 찍으면 한 달이면 디스크가 터진다.
Docker가 기본값을 안 잡아주는 이유
즉, 이 동작은 버그가 아니라 설계 결정이다. 데몬은 호스트 디스크가 얼마나 되는지, 외부 수집기가 있는지를 알 수 없으니 합리적인 기본 한도를 잡을 수 없다는 입장이다(관련 논의는 GitHub moby/moby #10989에서 확인 가능). 이건 합리적이긴 한데, 잘 안 알려진 사실이라는 게 진짜 문제다.
처음 Docker를 도입하는 팀 중 logging 항목을 따로 챙기는 경우가 많지 않다. 그래서 디스크 풀 사고가 흔하게 난다.
어떤 컨테이너가 가장 많이 찍는가
반면, 운영 환경에서 패턴은 비슷하다. Nginx access log를 stdout으로 빼는 게이트웨이, JSON 구조 로그를 매 요청마다 찍는 FastAPI/Django 백엔드, healthcheck 응답을 INFO 레벨로 매번 남기는 워커 서버. 이 세 종류가 디스크의 80%를 먹는 게 보통이다.
healthcheck 로그는 의외로 자주 간과된다. 5초 간격으로 healthcheck를 도는 컨테이너 50개를 띄우면 분당 600줄, 하루 86만 줄이다. 한 줄에 300바이트면 일 250MB. 이게 누적된다.
기존 접근의 한계 — logrotate, cron 삭제, 수동 truncate
게다가, 운영팀이 흔히 쓰는 우회책이 셋 있다. 각각 동작은 하지만 본질을 못 건드린다.
반면, 1) 호스트 logrotate로 json 로그 파일을 직접 회전한다
/etc/logrotate.d/docker-containers에 룰을 추가하는 방식이다. 동작 자체는 한다. 그런데 Docker 데몬이 열고 있는 파일 핸들을 logrotate가 회전시키면, 데몬이 새 파일을 만들지 못한 채 빈 핸들에 계속 쓰는 케이스가 나온다. copytruncate 옵션을 써도 회전 순간 일부 로그가 잘린다.
따라서, 2) cron으로 오래된 로그 파일 삭제
find /var/lib/docker/containers -name "*.log" -size +500M -delete를 매시간 도는 방식이다. 안전해 보이지만 -delete가 동작하는 순간 Docker 데몬이 그 파일에 쓰고 있으면 inode 참조가 남아서 디스크 공간은 회수되지 않는다. 컨테이너를 재시작하기 전까지 디스크가 비지 않는다.
물론, 3) truncate -s 0로 활성 로그 파일을 비운다
물론, 가장 빠르다. 단, 응급 대응용이지 일상 운영용은 아니다. 운영 중인 컨테이너의 로그를 자르면 그 순간 일부 메시지는 잃는 게 자명하다.
그런데, 세 방법 모두 호스트 쪽에서 파일을 건드린다. 본질은 Docker 데몬에게 "이 파일을 회전해라"라고 알려주는 것이다. 그 방법이 json-file 드라이버의 옵션이다.
daemon.json으로 호스트 전역 기본값 설정
그런데, 호스트 전역 기본값은 /etc/docker/daemon.json에 잡는다. 파일이 없으면 새로 만든다.
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "10",
"compress": "true",
"labels": "app,env",
"tag": "{{.ImageName}}/{{.Name}}/{{.ID}}"
}
}
물론, 옵션을 하나씩 보면:
max-size: 로그 파일 한 개의 최대 크기.100m은 100MB. k/m/g 단위를 쓴다.max-file: 보관할 회전 파일 개수. 10개를 두면 활성 파일 1개 + 회전된 9개가 디스크에 남는다.compress: 회전된 파일을 gzip 압축. JSON 텍스트 기준 압축률이 평균 1/8 정도라 디스크 점유가 크게 줄어든다.labels,tag: 외부 수집기에서 로그를 식별하는 메타데이터다.
적용은 데몬 재시작으로 한다.
# daemon.json 문법 검사
sudo dockerd --validate
sudo systemctl restart docker
# 현재 적용된 드라이버 확인
docker info --format '{{.LoggingDriver}}'
# 옵션이 제대로 박혔는지는 컨테이너 단위로 본다
docker inspect <container> --format '{{json .HostConfig.LogConfig}}'
여기서 함정이 하나 있다. daemon.json 변경은 이미 실행 중인 컨테이너에는 적용되지 않는다. 컨테이너 생성 시점에 logging 설정이 박힌다. 따라서 적용 후에는 컨테이너를 한 번씩 재생성해야 효과가 난다(공식 문서 JSON File logging driver에 명시되어 있다).
compress 옵션의 함정
compress=true를 켜면 회전된 파일은 .gz로 압축된다. 좋아 보이지만 docker logs 명령으로는 압축된 회전 파일을 읽지 못한다. 즉 docker logs --tail 1000이 활성 파일만 보고 그 이전 회전된 파일은 안 보인다. 회고가 필요하면 호스트에서 직접 gunzip을 해서 봐야 한다.
이 동작은 v20.10.13에서 추가된 옵션이라(Release Notes 참고), 그 이전 버전에서는 옵션을 그냥 무시한다. 24.x, 25.x에서는 정상 동작한다.
max-file을 너무 크게 두지 마라
그런데, 회전 파일 개수가 많을수록 좋아 보이지만, 데몬이 회전을 트리거할 때 매번 N개의 파일 이름 변경 작업이 일어난다. *-json.log.1.gz → .2.gz, .2.gz → .3.gz 식으로 모든 회전 파일을 한 칸씩 밀어낸다. max-file이 100이면 이 작업이 100번 일어난다. I/O 부담이 적지 않다.
반면, 경험적으로 컨테이너 한 개당 10개 안팎이 적당하다. 그 이상이 필요하면 max-size를 키우는 쪽이 낫다.
docker-compose.yml의 logging 옵션 — 서비스별 세분화
전역 기본값으로 부족한 경우가 있다. 예를 들어 Nginx는 50MB × 5개로 충분한데, 백그라운드 워커는 디버그 로그가 많아서 200MB × 20개가 필요하다. 이때는 docker-compose.yml에서 서비스별로 덮어쓴다.
version: "3.9"
services:
api:
image: myorg/api:1.4.2
logging:
driver: json-file
options:
max-size: "100m"
max-file: "10"
compress: "true"
labels: "app,env"
tag: "api/{{.Name}}"
labels:
app: "api"
env: "prod"
worker:
image: myorg/worker:0.9.1
logging:
driver: json-file
options:
max-size: "200m"
max-file: "20"
compress: "true"
nginx:
image: nginx:1.27-alpine
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
서비스별 logging은 daemon.json 기본값을 부분 머지하지 않고 완전히 덮어쓴다. 옵션 일부만 적어두면 나머지는 기본값을 쓰는 게 아니라 옵션 자체가 빠진 상태가 된다. 누락된 항목은 Docker 내부 디폴트(unlimited 등)로 돌아간다. 이 점 때문에 docker-compose에 logging을 쓸 때는 항상 옵션 전체 세트를 같이 명시하는 편이 낫다.
docker compose v2와 docker-compose v1의 차이
게다가, 오래된 시스템에서 docker-compose v1(파이썬 구현)을 쓰면 일부 logging 옵션이 무시된다. compose v2(docker compose, 공백 띄어쓰기) 이후로는 거의 모든 옵션이 정상 동작한다. v1은 2023년부로 deprecated 처리됐으니 가능하면 v2로 옮기는 게 맞다(Compose Specification 1.16.0 기준).
검증 — 부하 테스트로 확인한 로그 유실 패턴
설정만 잡고 끝낼 일이 아니다. 회전이 어떻게 일어나는지, 어디서 메시지가 잘리는지 직접 봐야 한다.
테스트는 간단하다. busybox 컨테이너로 초당 10MB 정도의 로그를 30분 동안 찍게 한다.
docker run -d --name log-stress \
--log-driver json-file \
--log-opt max-size=100m \
--log-opt max-file=5 \
--log-opt compress=true \
busybox sh -c \
'while true; do
head -c 10485760 /dev/urandom | base64;
sleep 1;
done'
회전 결과는 호스트에서 직접 본다.
ls -lh /var/lib/docker/containers/$(docker ps -qf name=log-stress)/
활성 파일 1개(*-json.log)와 회전된 파일 4개(*-json.log.1.gz ~ *-json.log.4.gz)가 보인다. 활성 파일이 100MB에 도달하면 데몬이 회전을 시작한다. 이때 약 0.3~1초 사이의 짧은 정지 구간이 있다. 회전 도중에는 stdout에 쓴 메시지가 데몬 메모리 버퍼에 잠시 대기한다.
또한, 문제는 이 버퍼가 무한이 아니라는 점이다. 회전 작업이 길어지거나 디스크 I/O가 밀리면 버퍼가 가득 차고, 그 시점의 메시지가 드랍된다. 버퍼 크기는 16KB 정도로 잡혀 있다(소스: moby/moby 저장소의 daemon/logger/jsonfilelog/jsonfilelog.go).
유실을 측정하는 방법
따라서, 순번이 박힌 로그를 찍어보면 유실 여부를 정량으로 잡을 수 있다.
# log_probe.py
import sys, time
i = 0
while True:
print(f"SEQ:{i}", flush=True) # flush 필수
i += 1
if i % 100000 == 0:
time.sleep(0.01)
예를 들어, 이걸 컨테이너로 돌리고 30분 뒤 다음 명령으로 카운트한다.
docker logs log-stress 2>&1 \
| grep -oP 'SEQ:\d+' \
| sort -u | wc -l
그리고 마지막에 출력된 SEQ 번호와 비교한다. 회전이 활발하게 일어난 환경에서는 둘이 일치하지 않는 케이스가 있다. 체감상 회전 1회당 수십 라인 정도 드랍되는 경우가 있었다. 초당 10MB 환경에서 30분 동안 회전 12회면 누계 수백 라인 수준이라 무시 가능한 비율이긴 한데, 있다는 사실 자체가 중요하다.
mode=non-blocking 옵션
log-opts에 mode=non-blocking을 추가하면 데몬 버퍼가 가득 찼을 때 컨테이너 stdout 쓰기 자체가 차단되지 않고 그냥 로그를 버린다. 기본값은 mode=blocking이라 버퍼가 가득 차면 애플리케이션의 stdout write 호출이 블록된다.
{
"log-opts": {
"mode": "non-blocking",
"max-buffer-size": "10m"
}
}
non-blocking은 로그 드랍을 받아들이는 대신 애플리케이션 성능을 지킨다. blocking은 애플리케이션 응답 시간을 희생해서 로그를 안 잃는다. 트래픽이 많은 API는 보통 non-blocking + 외부 수집기 조합이 안전하다. 로그가 회계나 감사 용도면 blocking이 맞다. 트레이드오프를 알고 골라야 한다.
json-file의 한계와 대안 — journald, fluentd, awslogs
또한, json-file은 단순하고 빠르지만 한계가 분명하다.
| 드라이버 | 장점 | 한계 |
|---|---|---|
| json-file | 기본, docker logs 호환, 빠름 |
회전 시 드랍 가능, 호스트 디스크 의존 |
| journald | systemd 로그와 통합, journalctl 조회 | 컨테이너 메타가 제한적, 멀티라인 까다로움 |
| fluentd | 외부 수집기로 즉시 전송 | fluentd 다운 시 컨테이너가 멈출 위험 |
| awslogs | CloudWatch 직결, 호스트 디스크 무관 | API 호출 비용, 네트워크 의존, 약간의 지연 |
그런데, 운영에서 로그 유실을 0으로 만들고 싶다면 json-file 단독으로는 어렵다. json-file로 디스크 캡을 잡되, 외부 수집기(Promtail, Vector, Fluent Bit 중 하나)를 호스트에 띄워서 회전 전 파일을 비동기로 빨아내는 구성이 현실적이다.
그 외에도, 수집기가 활성 파일을 tail하는 동안 회전이 일어나면 일부 라인을 놓칠 수 있는데, Vector는 file source의 fingerprinting과 read_from: beginning 옵션으로 이 문제를 어느 정도 처리해준다(Vector 0.38 기준).
한계점 정리와 당장 할 수 있는 액션
이 설정으로 모든 문제가 끝났다고 말하기는 어렵다. 회전 순간의 미세 드랍, 압축 회전 파일에 대한 docker logs 호환 부족, 외부 수집기와의 race condition은 여전히 남는다. 그래도 80GB 디스크 풀 사고를 막는 가성비는 압도적이다.
당장 적용 가능한 액션 셋:
/etc/docker/daemon.json에max-size,max-file,compress를 잡고 데몬을 재시작한다. 운영 중인 컨테이너는 한 번씩 재생성해야 옵션이 박힌다.- docker-compose.yml의
logging블록으로 서비스별 한도를 조정한다. Nginx와 healthcheck 로그가 많은 컨테이너는 작게, 디버그 로그가 많은 워커는 크게. - 트래픽이 높은 API에 한해
mode=non-blocking을 검토한다. 단, 로그 드랍이 허용되는 비즈니스인지를 먼저 확인해야 한다.
개인적으로는 json-file을 디스크 캡 용도로만 쓰고, 장기 보관과 검색은 Loki + Promtail 같은 외부 스택에 맡기는 구성이 가장 손이 덜 가는 편인 것 같다.
관련 글
- dockerfile 최적화 레이어 캐시 — 빌드 8분에서 90초로 줄인 실전 가이드 – docker build에서 한 줄짜리 문서 수정만 해도 매번 npm install이 다시 도는 이유는 레이어 캐시가 깨졌기 때문이다. 명령…
- Docker 컨테이너 DNS 설정 트러블슈팅 — 서비스 이름 해석 실패와 TTL 조정 – Docker 컨테이너 DNS 설정 때문에 두 번 막혔다. default bridge에서 서비스 이름이 안 풀린 게 첫 번째, alpine …
- Docker Multi-Stage Build 완벽 가이드 — 이미지 크기 90% 줄이는 실전 방법 – Docker multi-stage build는 빌드 환경과 런타임 환경을 분리해 최종 이미지에서 불필요한 도구를 제거하는 표준 패턴이다. …