트러블슈팅 — 증상에서 원인으로
마지막 편은 실제로 마주치는 증상에서 출발한다. 앞의 다섯 편에서 본 것들이 현장에서 어떤 모습으로 나타나는지의 목록이다.
1. 컨테이너가 자꾸 재시작된다
$ docker ps -a
STATUS: Restarting (137) 3 seconds ago
| 137 + OOMKilled=true | 메모리 제한 초과 | |
|---|---|---|
| 137 + OOMKilled=false | 유예 시간 안에 안 죽어 강제 종료 (PID 1 문제) | |
| 1 | 애플리케이션 예외 — 로그를 본다 | |
| 127 | 명령을 못 찾음 — 경로·libc 불일치 | |
| 126 | 실행 권한 없음 |
-
② 마지막 로그를 본다
- docker logs --tail 200 web
-
③ 재시작 루프면 로그가 계속 밀린다
- docker logs --since 10m web | head -50 ← 처음 부분을 본다
OOMKilled 인데 애플리케이션 로그가 없다
- 커널이 SIGKILL 로 죽인 것이라 정리 코드가 돌지 않는다
- 애플리케이션 입장에서는 아무 일도 안 일어난 것처럼 보인다
확인
dmesg | grep -i "killed process" 호스트 커널 로그
docker events --filter event=oom 실시간 이벤트
원인은 대개 둘 중 하나다
- 제한이 실제 사용량보다 작다
- 애플리케이션이 컨테이너 제한을 모르고 호스트 메모리 기준으로 잡는다
런타임이 제한을 인식하는지 확인한다
JVM 은 Java 10+ 에서 컨테이너 제한을 인식한다 (UseContainerSupport, 기본 켜짐)
- -XX:MaxRAMPercentage=75 로 limit 대비 비율로 힙을 잡는다
- -Xmx 를 직접 주면 limit 이 바뀌어도 따라가지 않는다
주의 — 힙 밖에도 메모리가 든다
- 메타스페이스 · 코드 캐시 · 스레드 스택 · 다이렉트 버퍼 · GC 구조
- 힙을 limit 의 100% 로 잡으면 반드시 OOMKilled 된다
- 실무 감각은 limit 의 60~75%
2. CPU 는 여유로운데 느리다
docker stats 에서 CPU 40% 인데 응답이 튄다
CPU 스로틀링을 의심한다
--cpus=1.0 은 '평균 1코어' 가 아니라 '주기당 상한' 이다
- 기본 주기 100ms 에 100ms 만 쓸 수 있다
- 멀티스레드 애플리케이션이 순간적으로 4코어를 쓰려 하면
- 25ms 만에 할당량을 다 쓰고 나머지 75ms 를 강제로 쉰다
- 평균 사용률은 낮게 보이는데 지연은 튄다
확인 — cgroup 의 스로틀 통계
cat /sys/fs/cgroup/cpu.stat
nr_throttled 스로틀된 주기 수
throttled_usec 스로틀된 총 시간
nr_throttled 가 계속 늘면 확정이다
대응
- limit 을 올린다 (가장 단순)
- 애플리케이션의 스레드 풀·병렬도를 limit 에 맞춘다
- (코어 수를 호스트 기준으로 잡으면 과도한 병렬이 된다)
- GC 스레드 수도 확인한다 — 자동 산정이 호스트 코어를 볼 수 있다
3. 빌드가 매번 느리다
-
증상 — 소스 한 줄만 고쳤는데 의존성을 처음부터 다시 받는다
-
원인 — COPY 순서 (02편)
- COPY . . 이 의존성 설치보다 앞에 있다
-
확인 — 빌드 로그에서 어느 단계부터 CACHED 가 사라지는지 본다
- BuildKit 은 각 단계에 CACHED 를 표시해 준다
CI 에서만 캐시가 안 먹는 경우
- 러너가 매번 새 머신이라 로컬 캐시가 없다
- 레지스트리 캐시를 쓴다
- docker buildx build \
- --cache-from type=registry,ref=myrepo/cache \
- --cache-to type=registry,ref=myrepo/cache,mode=max
- 레지스트리 캐시를 쓴다
- 빌드 컨텍스트에 매번 바뀌는 파일이 들어간다 (타임스탬프 · 빌드 번호 파일)
- .dockerignore 로 제외
빌드 캐시가 디스크를 먹는 것도 흔하다 docker system df -v 로 Build Cache 항목을 본다 docker builder prune --filter until=168h
4. 로컬에서는 되는데 서버에서 안 된다
-
① 아키텍처 불일치
- 증상: exec format error
- 애플 실리콘에서 빌드 → x86 서버
- 확인: docker image inspect myapp --format '{{.Os}}/{{.Architecture}}'
- 대응: buildx 로 멀티 아키텍처 빌드
-
② libc 불일치
- 증상: "not found" 인데 파일은 있다
- glibc 로 빌드한 바이너리를 alpine(musl)에 올렸다
- 확인: ldd ./app
- 대응: 정적 빌드 · 같은 계열 베이스
-
③ 태그가 같은데 이미지가 다르다
- latest 를 쓰면 노드마다 다른 이미지가 떠 있을 수 있다
- 확인: docker inspect --format '{{index .RepoDigests 0}}'
- 대응: 다이제스트·불변 태그로 고정
-
④ 파일이 이미지에 안 들어갔다
- .dockerignore 가 필요한 파일까지 제외했다
- 확인: docker run --rm myapp ls -al /app
5. 디스크가 찼다
docker system df -v
- TYPE — RECLAIMABLE
- Images — ...
- Containers — ...
- Local Volumes — ...
- Build Cache — ... ← 여기가 가장 큰 경우가 많다
정리 순서 (위험도 낮은 것부터)
docker container prune 정지된 컨테이너
docker builder prune 빌드 캐시
docker image prune dangling 이미지 (태그 없는 것)
docker image prune -a 사용 중이 아닌 이미지 전부 ← 신중
docker volume prune 참조되지 않는 볼륨 ← 데이터 손실 주의
한 번에 지우기 전에 --dry-run 이 없는 명령이므로 docker system df -v 로 무엇이 큰지 먼저 확인한다
로그가 채우는 경우
json-file 드라이버는 기본 무제한이다 (04편)
- /var/lib/docker/containers/<id>/<id>-json.log
확인
- du -sh /var/lib/docker/containers/* | sort -h | tail
대응
- daemon.json 에 max-size · max-file 설정 후 데몬 재시작
- 기존 컨테이너는 재생성해야 적용된다
6. 네트워크가 안 된다
-
① 컨테이너끼리 이름으로 못 찾는다
- 기본 bridge 에는 내장 DNS 가 없다
- 사용자 정의 네트워크를 만들어 붙인다
-
② localhost 로 연결이 안 된다
- 컨테이너 안의 localhost 는 자기 자신이다
- 다른 컨테이너는 이름, 호스트는 host.docker.internal
-
③ 포트가 안 열린다
- EXPOSE 는 문서일 뿐 (03편) — -p 로 매핑해야 한다
- 확인: docker port web
- ss -lnt | grep 8080 호스트에서 실제로 열렸는지
-
④ 방화벽으로 막았는데 열려 있다
- 도커가 iptables 를 직접 만져 ufw 규칙보다 앞선다
- -p 127.0.0.1:8080:80 으로 바인딩 주소를 제한하거나
- 도커의 iptables 관리를 끄고 직접 규칙을 쓴다
# 안에서 확인
docker exec -it web sh -c 'cat /etc/resolv.conf; nslookup db; wget -qO- http://db:5432'
# 네트워크 구성 확인
docker network inspect appnet --format '{{json .Containers}}' | python3 -m json.tool
7. 셸이 없는 이미지를 디버깅한다
# 실행 중인 컨테이너의 네임스페이스를 공유하는 임시 컨테이너
docker run --rm -it \
--pid=container:web --network=container:web \
--cap-add=SYS_PTRACE \
nicolaka/netshoot sh
--pid=container:web 그 컨테이너의 프로세스를 본다 --network=container:web 그 컨테이너의 네트워크에서 진단한다
쿠버네티스에는 같은 목적의 임시 컨테이너 기능이 있다
- kubectl debug -it pod/web --image=nicolaka/netshoot --target=web
운영 이미지에서 셸을 빼는 대신 이 경로를 갖추는 것이 정석이다
8. 진단 명령 정리
# 상태
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker inspect web --format '{{.State.Status}} {{.State.ExitCode}} {{.State.OOMKilled}}'
docker stats --no-stream
docker events --since 30m
# 안쪽
docker top web
docker diff web # 이미지 대비 바뀐 파일
docker exec -it web sh
# 이미지
docker history --no-trunc myapp:1.0
docker image inspect myapp:1.0 --format '{{.Os}}/{{.Architecture}} {{.Config.User}}'
# 자원
docker system df -v
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.stat # 컨테이너 안에서
# 호스트
dmesg | grep -i 'killed process'
journalctl -u docker --since '30 min ago'
9. 배포 전 체크리스트
□ ENTRYPOINT/CMD 가 exec form(대괄호)인가 → 시그널 전달 □ USER 를 숫자 UID 로 지정했는가 → root 실행 방지 □ .dockerignore 에 .git · .env 가 있는가 → 비밀 유출 □ 태그가 불변인가 (latest 아님) → 재현·롤백 □ 메모리·CPU limit 을 걸었는가 → 호스트 보호 □ 런타임이 limit 을 인식하는가 (MaxRAMPercentage) → OOMKilled 예방 □ 로그 드라이버에 max-size 가 있는가 → 디스크 보호 □ 유예 시간이 최장 요청 처리 시간보다 긴가 → 무중단 □ 헬스체크가 정의돼 있는가 → 자동 복구 □ 이미지 스캔에서 Critical 이 남아 있지 않은가 → 보안 □ 멀티 아키텍처가 필요한 환경인가 → exec format error
한눈에 정리
- 재시작 루프 — 종료 코드부터. 137+OOMKilled 로 갈린다
- OOMKilled — 로그가 안 남는다. dmesg · docker events 로 확인
- 런타임이 limit 을 인식하는지, 힙 밖 메모리를 봤는지
- CPU 스로틀 — 사용률은 낮은데 지연이 튄다. cpu.stat 의 nr_throttled
- 빌드 느림 — COPY 순서. CI 는 레지스트리 캐시. builder prune
- 로컬만 됨 — 아키텍처 · libc · 태그 불일치 · dockerignore 과다 제외
- 디스크 — system df -v 로 먼저 확인. Build Cache 가 큰 경우가 많다
- 네트워크 — 사용자 정의 네트워크 · localhost 오해 · EXPOSE 는 문서
- 셸 없는 이미지 네임스페이스 공유 임시 컨테이너 · kubectl debug
출처 — Docker Docs: "Troubleshoot" · "Runtime options with Memory, CPUs, and GPUs" · "Configure logging drivers" · "Multi-platform builds" · Linux
cgroups(7)· Kubernetes Docs "Debug Running Pods"