“서비스가 느려요.”
이 한마디가 도착하면 엔지니어의 손이 바빠진다.
API 로그를 받고, 프록시 로그를 받고, 컨테이너 로그를 받고, DB 로그를 받는다. 혹시 모르니 Agent 로그도 받고, CPU와 메모리도 캡처한다. 압축 파일은 금세 몇 GB가 된다.
그런데 정작 가장 중요한 정보는 빠져 있다.
언제부터 느렸는가?
이건 응급실에 환자가 들어왔는데 문진은 건너뛰고 전신 MRI부터 찍는 것과 비슷하다. 검사는 많이 했지만 어디가 아픈지 모른다.
운영 장애에서 중요한 건 로그의 양이 아니다.
사건의 시간과 실패한 경계를 얼마나 빨리 좁히느냐다.
로그 수집은 창고 정리가 아니라 응급실 트리아지다
좋은 장애 대응은 세 단계로 움직인다.
1단계: 활력징후 확인
2단계: 의심 부위 정밀검사
3단계: 필요한 곳만 상세 촬영
서버 운영 언어로 바꾸면 이렇다.
1단계: 시각·증상·컨테이너 상태
2단계: thread dump·메트릭·DB pool
3단계: 의심 컴포넌트만 DEBUG
처음부터 모든 로그를 받지 않는다.
먼저 “어디까지 정상적으로 도착했는지”를 본다. 브라우저 요청이 Nginx에 도착했는지, API가 요청을 받았는지, Proxy가 세션을 만들었는지, Target DB까지 연결했는지를 차례로 확인한다.
경계 하나를 통과할 때마다 용의자가 줄어든다.
1단계: 활력징후부터 본다
장애가 발생하면 가장 먼저 아래 정보를 적는다.
발생 시각과 timezone:
영향받은 사용자 또는 기능:
실패 단계:
오류 메시지:
재현 가능 여부:
최근 배포·설정 변경:
시간은 “점심쯤”이 아니라 초 단위로 남기는 게 좋다.
date '+%Y-%m-%d %H:%M:%S.%3N %z'
이 명령은 현재 시각을 밀리초와 timezone까지 보여준다.
예를 들면 이런 결과가 나온다.
2026-07-20 13:05:17.382 +0900
왜 timezone까지 필요할까?
사용자 PC는 KST인데 서버는 UTC일 수 있다. “13시 5분 오류”를 서버 로그의 13시 5분에서 찾으면 전혀 다른 사건을 잡을 수도 있다.
그다음 컨테이너 상태를 본다.
docker compose ps -a
이 명령은 컨테이너가 실행 중인지, 종료됐는지, 재시작 중인지 보여준다.
여기서 중요한 함정이 있다.
Up은 “프로세스가 살아 있다”는 뜻이지 “서비스가 정상이다”라는 뜻이 아니다. 식당 문이 열려 있다고 주방이 정상이라는 보장은 없는 것과 같다.
반대로 Restarting이라면 애플리케이션 기능보다 종료 직전 상황을 먼저 봐야 한다.
docker inspect <APP-CONTAINER> \
--format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}'
이 명령은 네 가지를 알려준다.
- 현재 상태
- 종료 코드
- 메모리 부족으로 종료됐는지
- 몇 번 재시작했는지
예를 들어 oom=true라면 “왜 API가 500을 냈지?”보다 “왜 메모리가 바닥났지?”가 먼저다.
로그는 사실 한 종류가 아니다
운영 중 흔히 “로그 좀 주세요”라고 말하지만, 로그는 서로 다른 질문에 답한다.
컨테이너 런타임 로그
docker logs \
--since '2026-07-20T12:55:00+09:00' \
--until '2026-07-20T13:15:00+09:00' \
--timestamps \
<APP-CONTAINER>
이건 컨테이너의 블랙박스에 가깝다.
프로세스가 시작하고 종료할 때 무슨 말을 남겼는지, stdout과 stderr에 어떤 오류가 찍혔는지 보여준다.
--since와 --until은 사건 전후 구간만 자르는 옵션이다. 장애가 13시 5분에 발생했다면 하루치 전체 로그보다 전후 10분이 훨씬 읽기 쉽다.
애플리케이션 파일 로그
API, Engine, Proxy, Nginx 같은 컴포넌트는 각자 파일 로그를 남길 수 있다.
이건 환자의 진료기록에 가깝다. 어떤 요청을 받았고 내부에서 어떤 처리를 했는지 비교적 긴 흐름을 보여준다.
컨테이너 로그와 파일 로그는 겹칠 수 있지만 같은 것은 아니다. 한쪽에만 기록되는 이벤트도 있다.
순간 상태 Dump
thread dump와 system metrics는 X-ray에 가깝다.
로그가 “무슨 일이 일어났는지” 시간순으로 보여준다면, dump는 “바로 그 순간 내부가 어떤 자세로 멈춰 있었는지” 보여준다.
예를 들면 이런 질문에 답한다.
- 같은 thread가 계속 CPU를 쓰고 있는가?
- 여러 thread가 같은 lock에서 기다리는가?
- DB connection pool의 idle이 바닥났는가?
- GC가 반복되는데 heap이 줄어들지 않는가?
그래서 서비스가 멈췄다면 재시작 버튼부터 누르는 게 아깝다.
재시작은 환자를 살릴 수 있지만, X-ray를 찍기 전에 자세를 바꿔버리는 일이기도 하다.
가능하면 먼저 dump를 남기자.
2단계: 증상에 맞춰 정밀검사를 고른다
모든 증상에 같은 검사를 할 필요는 없다.
화면에서 즉시 500 오류가 난다
먼저 받을 것은 네 가지면 충분하다.
1. 브라우저 Network의 요청과 응답
2. 같은 시각의 Nginx access/error 로그
3. 같은 시각의 API 로그
4. 애플리케이션 컨테이너 상태
판정은 이렇게 이어진다.
Nginx에 요청이 없다
→ 브라우저, DNS, Load Balancer 앞단을 본다
Nginx에는 있는데 API에 없다
→ upstream routing과 API process를 본다
API에 stack trace가 있다
→ 가장 먼저 발생한 예외부터 본다
이 단계에서 Agent 로그와 Target DB 전체 로그까지 요청하면 수집 시간만 길어진다.
Hikari connection timeout이 반복된다
이때는 API 로그만으로 부족하다.
Connection is not available, request timed out
이 메시지는 “DB가 죽었다”는 확정 판결이 아니다.
- pool이 꽉 찼을 수 있다.
- 장시간 transaction이 connection을 잡고 있을 수 있다.
- DB lock 때문에 반환이 늦을 수 있다.
- 애플리케이션이 connection을 제때 돌려주지 않았을 수 있다.
그래서 이때는 dump의 DB process와 pool 상태를 함께 봐야 한다.
active가 max에 가깝고 idle이 없다
→ pool 고갈 후보
DB process가 lock/wait 상태다
→ blocking query 후보
DB는 정상인데 같은 thread가 계속 기다린다
→ 애플리케이션 대기 지점 후보
로그 한 줄을 원인으로 승진시키지 않는 게 중요하다.
로그는 목격자다. 판사는 아니다.
CPU가 높고 화면이 멈춘다
이때는 dump 한 번보다 30~60초 간격의 dump 두세 개가 유용하다.
사진 한 장에서는 사람이 달리는지 서 있는지 알기 어렵다. 연속 사진을 보면 방향이 보인다.
같은 thread가 계속 RUNNABLE
→ CPU hotspot 후보
같은 lock에서 계속 BLOCKED
→ lock contention 후보
heap이 계속 증가하고 GC 후에도 회복되지 않음
→ memory pressure 후보
컨테이너 자원 상태도 함께 남긴다.
docker stats --no-stream <APP-CONTAINER>
--no-stream은 실시간 화면을 계속 띄우지 않고 현재 상태를 한 번만 출력한다. 장애 보고서에 붙이기 좋은 순간 사진이다.
3단계: DEBUG는 손전등이지 실내등이 아니다
기본 로그로 원인이 좁혀지지 않으면 상세 로그를 켤 수 있다.
하지만 전체 컴포넌트를 한꺼번에 DEBUG로 바꾸는 건 어두운 방을 찾겠다며 경기장 조명을 켜는 것과 비슷하다.
너무 밝아서 오히려 중요한 흔적이 묻힌다.
- 로그량이 급증한다.
- 디스크 사용량이 늘어난다.
- SQL이나 개인정보가 더 자세히 남을 수 있다.
- 정작 필요한 컴포넌트의 사건 순서를 찾기 어려워진다.
좋은 순서는 이렇다.
1. 현재 로그 레벨을 기록한다
2. 의심 컴포넌트만 DEBUG로 바꾼다
3. 조건을 고정해 한 번 재현한다
4. 발생 시각을 적는다
5. 로그를 수집한다
6. 기존 레벨로 원복한다
7. 원복 결과를 확인한다
특히 6번과 7번이 중요하다.
운영 장애 대응에서 “DEBUG를 켰다”는 작업은 절반만 끝난 상태다. 원복을 확인해야 닫힌다.
여러 서버가 있으면 ‘누가 받았는지’부터 찾는다
HA 환경에서는 같은 요청을 여러 노드가 처리할 수 있다.
사용자는 하나의 URL만 보지만 내부에서는 Load Balancer가 Node A 또는 Node B를 고른다.
이때 Node A의 로그만 받아서 “기록이 없네요”라고 말하면 안 된다. 요청을 Node B가 받았을 수 있다.
최소한 아래를 함께 남기자.
재현 시각
실제 처리 노드
노드별 컨테이너 상태
누락된 노드
Load Balancer 또는 ingress 정보
“로그가 없다”와 “다른 서버를 봤다”는 전혀 다른 결론이다.
장애는 사진 한 장이 아니라 짧은 영화다
여기서 한 단계 더 까다로운 장면이 있다.
애플리케이션이 죽어서 자동 재기동됐는데, 다시 뜨는 과정에서 또 실패한 경우다.
겉으로는 프로세스 ID가 남아 있다. 정적 페이지도 열린다. 그런데 실제 업무 요청은 계속 실패한다. 자동 재기동 관리자는 “프로세스가 있으니 살아 있네”라고 보고, Load Balancer도 얕은 검사만 통과하면 요청을 계속 보낸다.
이 상태를 한 줄로 줄이면 이렇다.
PID alive
≠ application initialized
≠ listener ready
≠ business request ready
식당 간판에 불이 켜져 있다고 주방이 주문을 받을 준비가 끝난 건 아니다.
이때 재기동 버튼부터 누르면 서비스는 살아날 수 있다. 하지만 왜 첫 프로세스가 종료됐는지, 왜 두 번째 startup이 실패했는지, 왜 장애 노드가 계속 트래픽을 받았는지는 한꺼번에 사라진다.
그래서 한 장의 사진 대신 네 장면을 이어 붙인다.
장면 1. 장애 Node A · 재기동 전
장면 2. 정상 Node B · 정확히 같은 시간
장면 3. Monitoring과 Load Balancer의 외부 증거
장면 4. Node A · 복구 후
장면 1: 재기동 전의 장애 노드
가장 값비싼 장면이다.
컨테이너 상태, 프로세스 목록, runtime과 애플리케이션 로그를 먼저 묶는다. Java 프로세스는 있는데 애플리케이션이 준비되지 않았다면 thread stack도 도움이 된다.
qpb \
-l 3 \
--id INC-20260723-1944 \
--from '2026-07-23T19:30:00+09:00' \
--to '2026-07-23T20:00:00+09:00' \
--logs nginx,api,engine,proxy \
--output ./node-a/pre-restart
이 명령에서 --from과 --to는 사건의 시작과 끝을 고정한다. --logs는 비교할 컴포넌트를 고르고, --output은 재기동 전 증거가 복구 후 자료에 덮이지 않게 분리한다.
서비스 영향이 크면 수집을 무한정 기다리면 안 된다. bundle이 만들어졌고 누락 항목이 manifest에 남았는지 확인한 뒤 복구한다.
장면 2: 정상 노드의 같은 30분
정상 Node B도 똑같은 Incident ID, --from, --to, 컴포넌트 목록으로 수집한다.
여기서 정상 노드는 단순한 백업이 아니다. 대조군이다.
두 노드에 같은 경고가 있다
→ 원인과 무관한 평소 소음일 수 있다
장애 노드에만 startup fatal이 있다
→ 복구 실패를 가르는 신호다
정상 노드에는 readiness 요청이 계속 오는데
장애 노드에는 없다
→ LB 경로와 설정을 다시 봐야 한다
의학 검사도 아픈 부위만 보지 않는다. 반대편과 비교해야 비대칭이 보인다.
장면 3: 서버 밖의 증거
서버 로그만으로는 Load Balancer가 왜 장애 노드를 계속 선택했는지 알 수 없다.
별도로 봐야 할 것은 다음과 같다.
- 장애 시점에 이미 생성된 Monitoring Dump
- CPU·memory·I/O와 GC 시계열
- Load Balancer의 protocol·port·health path
- 양쪽 Target Health 전환 시각과 reason
- process manager의 시작·종료 기록
이 자료는 자동 수집기가 마음대로 cloud 계정에 들어가 가져오거나, 과거 시점으로 돌아가 새로 만들 수 없다.
특히 재기동 후 만든 thread dump는 과거 장애 순간을 복원하지 못한다. 환자가 자세를 바꾼 뒤 찍은 X-ray로 넘어지기 직전의 자세를 재현할 수 없는 것과 같다.
이미 export한 자료는 명시적으로 첨부한다.
qpb \
--attach ./monitoring-dump.zip \
--attach ./lb-target-health.json
--attach는 사람이 선택하고 검토한 파일만 bundle에 넣는다. 도구가 사용자 홈 디렉터리를 뒤져 “관련 있어 보이는 파일”을 몰래 주워 담지 않는다는 뜻이기도 하다.
장면 4: 복구 후의 같은 검사
복구 뒤에는 짧은 bundle을 하나 더 만든다.
목적은 “재기동 명령이 성공했다”를 증명하는 게 아니다.
startup fatal이 사라졌는가?
프로세스가 안정적으로 유지되는가?
readiness가 연속 성공하는가?
내부 의존 경로와 대표 업무 요청이 성공하는가?
같은 오류가 다시 쌓이지 않는가?
container Up이나 정적 version 페이지의 200만으로는 부족하다. 복구 판정은 서비스가 실제 주문을 받을 수 있는지까지 가야 한다.
네 장면이 모이면 원인도 네 층으로 나뉜다
좋은 장애 분석은 원인을 하나의 긴 문장에 구겨 넣지 않는다.
Trigger
최초 프로세스가 왜 느려지거나 종료됐는가?
Recovery failure
자동 재기동된 애플리케이션이 왜 Ready에 도달하지 못했는가?
Amplifier
왜 실패 상태가 오래 유지됐는가?
Exposure
왜 사용자 요청이 장애 노드에 계속 도달했는가?
예를 들어 startup에서 발견된 예외는 Recovery failure를 정확히 설명할 수 있다. 하지만 그 예외만으로 최초 CPU 급증이나 프로세스 종료의 Trigger까지 설명했다고 말하면 안 된다.
이 구분이 중요한 이유는 조치가 달라지기 때문이다.
Trigger → 자원·GC·dependency 원인 개선
Recovery → startup 코드와 lifecycle 개선
Amplifier → process 종료·재기동 조건 개선
Exposure → readiness와 LB 격리 기준 개선
장애 보고서가 갑자기 훨씬 솔직해진다. “원인을 찾았다”가 아니라 “어느 층까지 증명했고 무엇이 아직 비어 있는지”를 말할 수 있기 때문이다.
수집 파일에는 설명서도 넣자
로그 ZIP을 열었는데 이런 파일만 있다고 해보자.
api.log
runtime.log
metrics.txt
dump.zip
일주일 뒤 누가 이 파일을 기억할까?
그래서 ZIP 최상위에 작은 manifest.txt를 두는 게 좋다.
수집 시작/종료 시각과 timezone:
서비스 버전:
대상 node/container:
재현 구간:
포함 파일과 크기:
누락 또는 실패한 항목:
상세 로그 변경 전 값:
상세 로그 원복 확인 값:
민감정보 마스킹 여부:
이 파일은 증거 상자의 라벨이다.
좋은 자동화는 로그를 많이 모으는 자동화가 아니다. 무엇을 모았고, 무엇을 못 모았는지 정직하게 말하는 자동화다.
그러다 런북이 실행 파일이 됐다
여기까지 정리하고 보니 한 가지 문제가 남았다.
문서는 친절해졌는데, 장애가 날 때마다 사람은 여전히 같은 명령을 복사하고 있었다.
컨테이너 상태 확인
runtime log 시간 범위 지정
API·Engine·Proxy 로그 선별
CPU·메모리 snapshot 저장
파일 이름 정리
ZIP 압축
checksum 생성
누락 항목 기록
한두 번은 괜찮다.
하지만 새벽 2시에 이 목록을 만나면 이야기가 달라진다. 따옴표 하나를 빼먹고, 시간대를 헷갈리고, 다른 서버의 로그를 압축하기 쉽다.
그래서 수동 런북을 작은 CLI로 옮겼다. 이름은 qpb, Support Bundle을 만드는 단일 실행 파일이다.
여기서 중요한 설계 원칙은 단순했다.
판단은 사람이 하고, 반복 수집은 도구가 한다.
도구가 “이 장애는 DB 문제입니다”라고 추측하게 만들지 않았다. 대신 같은 시간 범위, 같은 파일 구조, 같은 manifest로 증거를 모으게 했다.
설치는 실행 파일 하나로 끝낸다
운영 서버에 Python package나 Node runtime을 추가하고 싶지는 않았다. 장애 수집 도구를 설치하다가 운영 환경을 바꾸는 건 체온계를 쓰려고 환자에게 수술부터 하는 것과 비슷하다.
그래서 OS와 CPU에 맞는 실행 파일 하나만 복사하는 방식으로 만들었다.
mkdir -p "$HOME/.local/bin"
install -m 0755 qpb-linux-amd64 "$HOME/.local/bin/qpb"
"$HOME/.local/bin/qpb" version
install -m 0755는 파일을 복사하면서 실행 권한을 준다. Windows라면 qpb.exe를 작업 폴더에 두고 실행하면 된다.
물론 다운로드한 바이너리는 checksum과 코드 서명을 확인해야 한다. 장애 수집 도구 자체가 새로운 보안 사고의 입구가 되면 안 된다.
장애 유형 대신 수집 깊이를 고른다
처음에는 이런 메뉴를 만들까 고민했다.
API 장애
느린 쿼리
컨테이너 재시작
Agent 연결 실패
네트워크 장애
그런데 장애 초기에 가장 어려운 일이 바로 “이게 무슨 장애인지” 알아내는 것이다. 분류를 알아야 도구를 실행할 수 있다면 순서가 거꾸로다.
그래서 qpb는 장애 이름 대신 수집 깊이를 받는다.
qpb -l 1 # 빠른 활력징후
qpb -l 2 # 표준 진단 묶음, 기본값
qpb -l 3 # 추가 상태 snapshot
그리고 필요한 컴포넌트만 조합한다.
qpb -l 2 --logs api,engine,proxy
이 명령은 “원인은 Proxy다”라고 선언하지 않는다. 다만 API에서 Engine과 Proxy로 이어지는 경계를 같은 시간대에 비교할 수 있게 해준다.
먼저 계획을 보여주고, 그다음 수집한다
운영 도구에서 실행 버튼보다 반가운 기능이 있다.
미리 보기다.
qpb --plan
이 명령은 파일을 수집하지 않고 다음 내용을 먼저 보여준다.
- 어떤 장비로 판단했는지
- 어느 시간 범위를 볼지
- 어떤 로그를 모을지
- network check나 민감한 진단이 포함되는지
- 예상 출력 파일이 무엇인지
응급실에서도 검사 동의서 없이 바로 시술하지 않는다. 운영 서버도 마찬가지다.
network 확인과 packet capture는 다른 일이다
네트워크 장애라는 말이 나오면 곧바로 PCAP부터 떠올리기 쉽다.
하지만 DNS가 틀렸는지, TCP 연결이 거부되는지, TLS 인증서가 만료됐는지 확인하는 데 packet payload는 필요하지 않다.
그래서 둘을 분리했다.
qpb --network --peer example.com:443
이건 DNS, TCP, TLS, interface, route, listener 같은 읽기 전용 상태만 확인한다.
실제 packet capture는 별도 선택이다.
qpb --pcap --peer example.com:443 --ack-sensitive
PCAP은 payload에 개인정보와 인증 정보가 들어갈 수 있다. 따라서 명시적 동의, 대상 host와 port, 짧은 시간, 제한된 크기가 필요하다.
“네트워크를 본다”와 “모든 패킷을 저장한다” 사이에는 꽤 큰 보안의 강이 흐른다.
자동화하지 않은 것도 기능이다
Support Bundle이 생겼다고 모든 수동 절차를 없애지는 않았다.
자동화한 것
- 컨테이너 상태와 제한된 runtime log
- 선택한 애플리케이션 로그
- system/network snapshot
- manifest, ZIP, checksum
사람이 계속 하는 것
- Browser HAR와 Console export
- 순간 상태 Dump 생성과 해석
- Target 시스템 로그 확인
- DEBUG 변경, 한 번 재현, 원복 검증
- 외부 전달 전 민감정보 검토
브라우저 전체를 뒤지거나 사용자 홈 디렉터리를 마음대로 탐색하는 자동화는 편리해 보여도 위험하다. 사용자가 직접 고른 파일만 첨부하는 편이 낫다.
qpb --attach ./browser.har --attach ./monitoring-dump.zip
자동화의 성숙도는 “얼마나 많이 해주느냐”만으로 결정되지 않는다.
무엇을 하지 않는지 명확히 아는 도구가 더 믿을 만하다.
실패해도 증거 상자는 남긴다
진단 도구 하나가 없다고 지금까지 모은 자료를 모두 버리면 곤란하다.
그래서 개별 수집 실패는 manifest에 남기되, 가능한 경우 ZIP은 계속 만든다.
OK system state 수집 완료
OK API log 수집 완료
SKIP packet capture 도구 없음
WARN 요청한 Agent log를 찾지 못함
이런 bundle은 완벽하지 않다.
하지만 정직하다.
운영에서 “없음”과 “못 모음”은 완전히 다른 정보다. 전자는 사건의 특징일 수 있고, 후자는 수집 과정의 한계다.
결국 좋은 Support Bundle은 거대한 ZIP 제조기가 아니다.
수동 런북의 판단 기준은 그대로 두고, 사람이 반복하다 틀리기 쉬운 부분만 기계에 맡기는 장치다.
마지막 문은 보안이다
운영 로그에는 생각보다 많은 정보가 들어간다.
- 사용자 이름과 이메일
- IP와 hostname
- SQL과 결과 일부
- session, cookie, token
- connection string
- 내부 파일 경로
- 패킷의 네트워크 정보
압축했다고 안전해지는 건 아니다.
ZIP 안의 비밀번호도 비밀번호다.
외부에 전달하기 전에는 token, cookie, password, secret 값을 제거하고, PCAP은 필요한 host와 port, 재현 한 번으로 범위를 제한해야 한다.
상세 로그를 켰다면 원복도 확인한다.
좋은 장애 보고서는 범인을 맞히지 않는다
좋은 장애 보고서는 이렇게 말하지 않는다.
아마 DB 문제 같습니다.
아마 네트워크 문제 같습니다.
아마 Proxy 문제 같습니다.
대신 이렇게 말한다.
요청은 Nginx까지 도착했다.
API handler에서 첫 예외가 발생했다.
Target DB 연결은 시작되지 않았다.
Client가 먼저 timeout된 뒤 cleanup 오류가 발생했다.
특정 노드에서만 같은 오류가 반복된다.
추측이 아니라 경계를 말한다.
이렇게 정리하면 다음 담당자가 처음부터 사건을 다시 수사하지 않아도 된다.
로그 수집의 목표는 많이 모으는 것이 아니다
운영 장애에서 로그는 많을수록 좋은 게 아니다.
정확한 시각이 있는 로그,
같은 재현에서 나온 로그,
실패 경계를 보여주는 로그,
원복과 누락을 기록한 로그가 좋은 로그다.
오늘의 결론은 간단하다.
전부 달라고 하지 말자.
먼저 어디가 아픈지 묻자.
그리고 필요한 검사만 하자.
그러면 몇 GB짜리 ZIP보다 20분짜리 정확한 사건 기록이 훨씬 강해진다.