Table of contents
- 문제 진단의 큰 그림
- 자주 보는 exit code 해석
- 로그 읽는 법
- inspect — 메타데이터 전부
- events — 무슨 일이 있었는지 시간순으로
- stats — 실시간 리소스
- top — 컨테이너 내부 프로세스
- exec — 컨테이너 안으로
- 흔한 에러 패턴과 해결
- Docker 대안들
- 마이그레이션 시 고려사항
- Docker 시리즈 마무리
문제 진단의 큰 그림
사고가 발생했을 때 어디부터 봐야 하는지 흐름으로 정리한다.
flowchart TB
START["컨테이너 이상"] --> Q1{"docker ps에<br/>보이는가?"}
Q1 -->|아니오| NOEXIST["docker ps -a<br/>exit code 확인"]
Q1 -->|예| Q2{"상태가<br/>Running?"}
Q2 -->|Restarting| LOGS["docker logs<br/>+ events"]
Q2 -->|Running| Q3{"요청이<br/>닿는가?"}
Q3 -->|아니오| NET["inspect 네트워크<br/>+ port 확인"]
Q3 -->|느림/에러| STATS["stats / top<br/>리소스 확인"]
NOEXIST --> EXITC{"Exit Code"}
EXITC -->|0| NORMAL["정상 종료"]
EXITC -->|1| APP["앱 에러"]
EXITC -->|125| CLI["docker 명령 오류"]
EXITC -->|126| PERM["실행 불가 (permission)"]
EXITC -->|127| NOT["명령 없음"]
EXITC -->|137| OOM["SIGKILL (OOM 가능성 큼)"]
EXITC -->|143| TERM["SIGTERM (정상 종료 경로)"]
exit code만 봐도 원인의 절반은 좁혀진다. 하나씩 본다.
자주 보는 exit code 해석
| Exit code | 의미 | 흔한 원인 |
|---|---|---|
| 0 | 정상 종료 | 앱이 의도대로 끝남 |
| 1 | 앱 레벨 오류 | 스택트레이스 확인 |
| 125 | Docker 명령 자체 실패 | 옵션/이미지 이름 오타 |
| 126 | 컨테이너 내부 명령 실행 불가 | 권한 부족, 실행 비트 누락 |
| 127 | 명령을 찾을 수 없음 | PATH 이슈, 바이너리 부재 |
| 137 | SIGKILL로 종료 | OOM killer 또는 liveness 실패 |
| 139 | SIGSEGV | 네이티브 크래시 |
| 143 | SIGTERM으로 종료 | 오케스트레이터 정상 종료 경로 |
가장 오해가 많은 게 137이다. 바로 “OOM이다”라고 단정하기 쉬운데, 실제로는 Kubernetes liveness 실패로 kubelet이 SIGKILL을 보낸 경우도 137이다. 로그에 OOM 메시지가 없고 liveness 설정이 까다롭다면 probe부터 의심한다.
확인 명령.
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}'
docker inspect <컨테이너> --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Error}}'
OOMKilled: true면 진짜 메모리 초과다.
로그 읽는 법
docker logs는 컨테이너의 stdout/stderr를 그대로 보여준다. 실시간으로 따라가려면 -f.
docker logs <컨테이너>
docker logs -f <컨테이너>
docker logs --tail 200 <컨테이너>
docker logs --since 10m <컨테이너>
docker logs --timestamps <컨테이너>
Compose면 서비스 이름으로.
docker compose logs -f web
docker compose logs --tail 100 db
주의할 점 하나. 앱이 파일에만 로그를 쓰고 stdout에 안 쓰면 docker logs에 아무것도 안 나온다. 표준 출력으로 내보내도록 앱 설정을 고쳐야 한다(Node: console.log, Python: sys.stdout, Spring Boot: logging.file.name 설정 제거 등).
inspect — 메타데이터 전부
컨테이너/이미지/네트워크/볼륨에 대해 JSON으로 상세 정보를 뽑는다.
# 컨테이너 상태
docker inspect <컨테이너>
# 특정 필드만
docker inspect <컨테이너> --format '{{.State.Status}}'
docker inspect <컨테이너> --format '{{.NetworkSettings.IPAddress}}'
docker inspect <컨테이너> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
# 이미지
docker inspect <이미지>
# 네트워크
docker network inspect bridge
문제를 빠르게 좁히는 필드들.
.State.Health.Status— HEALTHCHECK 상태.State.Health.Log— 최근 헬스체크 결과(예: 마지막 5회).Config.Env— 환경 변수(시크릿이 여기 노출돼 있으면 문제).HostConfig.Memory,.HostConfig.NanoCpus— 실제 적용된 리소스 제한.NetworkSettings.Networks— 속한 네트워크와 IP
events — 무슨 일이 있었는지 시간순으로
docker events는 Docker 데몬의 실시간 이벤트 스트림이다. 컨테이너가 왜 죽었는지 시간순으로 쫓고 싶을 때 유용하다.
# 실시간
docker events
# 필터
docker events --filter 'event=die' --filter 'event=kill'
# 과거 이벤트
docker events --since '1h' --until '10m'
출력 예시.
2026-04-20T12:34:56 container die 5e9a...(image=myapp:1.4.2, exitCode=137)
2026-04-20T12:34:56 container oom 5e9a...
oom 이벤트가 함께 나오면 OOM killer 확정이다.
stats — 실시간 리소스
stats는 컨테이너별 CPU/메모리/네트워크/IO를 실시간으로 보여준다.
docker stats
docker stats <컨테이너> --no-stream
--no-stream은 한 번만 찍고 빠진다. CI/모니터링 스크립트에서 유용. 메모리 사용량이 limit에 가까워지고 있으면 OOM의 전조다.
top — 컨테이너 내부 프로세스
docker top은 컨테이너 안의 프로세스 목록이다.
docker top <컨테이너>
docker top <컨테이너> auxf # 트리 형태
PID 1이 sh -c ...로 떠 있으면 12편에서 다뤘던 시그널 전달 문제를 의심한다. 자식 프로세스가 예상보다 많이 보이면 포크 관련 이슈도 확인한다.
exec — 컨테이너 안으로
가장 많이 쓰는 진단 명령일 것이다.
docker exec -it <컨테이너> sh
docker exec -it <컨테이너> bash # bash가 있는 이미지에서
docker exec <컨테이너> cat /proc/1/status
docker exec <컨테이너> env
distroless처럼 셸이 없는 이미지라면 exec로 셸에 붙을 수 없다. 이럴 때는 네트워크 디버그 툴을 별도 컨테이너로 띄워 같은 네트워크 네임스페이스에 붙인다.
docker run -it --rm \
--network container:<컨테이너> \
--pid container:<컨테이너> \
nicolaka/netshoot
nicolaka/netshoot는 curl, dig, tcpdump, strace 등 진단 도구가 잔뜩 들어있는 디버깅용 이미지다. 네트워크/PID 네임스페이스를 공유하기 때문에 대상 컨테이너의 포트와 프로세스를 그대로 들여다볼 수 있다.
흔한 에러 패턴과 해결
1. permission denied — 볼륨 권한
open /app/logs/app.log: permission denied
원인: 호스트 디렉토리를 bind mount했는데, 컨테이너 안의 비루트 유저 UID와 호스트 파일 소유자 UID가 다르다.
해결:
- 컨테이너의 UID를 호스트 디렉토리 소유자와 맞춘다 (
--user 1000:1000) - 또는 named volume을 쓴다 (Docker가 권한 맞춤)
- 또는 이미지 빌드 시
RUN chown -R후USER지정
2. Error response from daemon: pull access denied
pull access denied for registry.example.com/myapp
원인: 레지스트리 로그인 안 됐거나, 토큰 만료, 리포 경로 오타.
해결:
docker login registry.example.com
docker pull registry.example.com/myapp:1.4.2
ECR 같은 경우 토큰이 12시간짜리라 CI 시작 시마다 갱신해야 한다.
3. address already in use
bind: address already in use
원인: 호스트의 해당 포트를 이미 다른 프로세스가 쓰고 있다.
해결:
lsof -i :8080 # Linux/macOS
# 또는
netstat -anp | grep 8080
# 충돌 컨테이너 찾기
docker ps --filter "publish=8080"
4. no space left on device
원인: 호스트 디스크가 찼거나, Docker 내부 스토리지 디렉토리(/var/lib/docker)가 찼다.
# 정리
docker system df # 용량 현황
docker system prune # 정지된 것, unused 네트워크/이미지 정리
docker system prune -a --volumes # 위 + 참조 없는 이미지 + 볼륨 (주의!)
docker builder prune # 빌드 캐시만 정리
정기적으로 builder prune을 도는 CI 잡 하나 두면 오래 안정적으로 유지된다.
5. CrashLoopBackOff (Kubernetes)
컨테이너가 기동하자마자 종료되는 상태. kubelet이 계속 재시작하면서 backoff가 기하급수로 늘어난다.
kubectl describe pod <pod> # 이벤트, 마지막 exit code
kubectl logs <pod> -c <container>
kubectl logs <pod> -c <container> --previous # 이전 인스턴스 로그
--previous가 결정적이다. 현재 컨테이너는 이미 죽어서 로그가 비었지만, 이전 인스턴스가 왜 죽었는지 로그가 남아있다.
Docker 대안들
Docker가 사실상 표준이지만, 전부가 아니다. 대안이 왜 존재하는지 이해하면 상황에 맞는 선택이 가능해진다.
flowchart TB
subgraph OCI["OCI 표준"]
OCI_SPEC["OCI Image Spec<br/>OCI Runtime Spec<br/>OCI Distribution Spec"]
end
subgraph TOOLS["고수준 도구"]
DOCKER["Docker (dockerd)"]
PODMAN["Podman"]
NERDCTL["nerdctl"]
end
subgraph RUNTIME["저수준 런타임"]
CONTAINERD["containerd"]
CRIO["CRI-O"]
RUNC["runc"]
end
DOCKER --> CONTAINERD
NERDCTL --> CONTAINERD
PODMAN --> RUNC
CONTAINERD --> RUNC
CRIO --> RUNC
OCI_SPEC -.표준.-> DOCKER
OCI_SPEC -.표준.-> PODMAN
OCI_SPEC -.표준.-> CONTAINERD
이 그림의 핵심은 이미지와 런타임이 표준화돼 있다는 것이다. Docker로 만든 이미지를 Podman이나 containerd가 그대로 돌릴 수 있다. 도구를 바꿔도 이미지는 호환된다.
Podman — 데몬리스 + rootless
Podman은 Red Hat이 주도하는 대안이다. 명령어 호환성이 높고(alias docker=podman으로 많은 경우 그냥 된다), 두 가지가 다르다.
- 데몬이 없다: Docker는 dockerd라는 상시 데몬이 있고 CLI가 이걸 호출하는 구조다. Podman은 각 명령이 직접 컨테이너를 기동한다. SystemD와 친하다(
podman generate systemd). - 루트리스가 기본: 일반 사용자로
podman을 쓰면 user namespaces를 활용해 루트 권한 없이 컨테이너를 돌린다. 공격 표면이 줄어든다.
# Docker와 같은 명령어가 대부분 통함
podman pull alpine:3.20
podman run -it alpine:3.20 sh
podman ps
podman build -t myapp:1.4.2 .
# SystemD 유닛 자동 생성
podman generate systemd --name myapp > myapp.service
대신 Compose 호환은 덜 매끄럽다. podman-compose라는 별도 도구가 있고, 최근 podman compose로 통합되긴 했지만 완벽히 호환되지는 않는다.
언제 Podman을 고르는가:
- 루트리스가 필수인 보안 환경(정부, 금융 등)
- Red Hat 계열 OS 표준 도구로 맞추고 싶을 때
- SystemD로 컨테이너를 서비스처럼 관리하고 싶을 때
containerd — Kubernetes의 표준 런타임
containerd는 원래 Docker에서 분리된 저수준 런타임이다. Docker는 containerd를 쓰고, Kubernetes도 containerd를 기본 CRI로 쓴다. Kubernetes 1.24부터 Docker Shim이 제거됐기 때문에 노드 런타임은 대부분 containerd 또는 CRI-O다.
# containerd는 자체 CLI 대신 nerdctl을 쓴다
nerdctl pull alpine:3.20
nerdctl run -it alpine:3.20 sh
nerdctl build -t myapp:1.4.2 .
nerdctl compose up -d
nerdctl은 Docker CLI와 거의 동일한 인터페이스를 제공한다. BuildKit 통합, Compose 지원, 이미지 암호화까지 — 사실상 “Docker CLI 없는 Docker”에 가깝다.
언제 containerd를 마주치는가:
- Kubernetes 노드를 다룰 때 (이미 쓰고 있을 가능성이 높음)
crictl명령으로 노드 수준에서 파드/컨테이너를 직접 확인할 때
# Kubernetes 노드에서
crictl ps
crictl logs <컨테이너 ID>
crictl exec -it <컨테이너 ID> sh
CRI-O
CRI-O는 Kubernetes 전용 런타임이다. OpenShift가 기본으로 쓴다. Kubernetes에 없는 기능은 일부러 안 넣는다는 철학이라 단순하고 가볍다. 일반 개발자가 직접 쓸 일은 거의 없고, 플랫폼 팀 범주다.
마이그레이션 시 고려사항
Docker에서 Podman 또는 containerd 기반으로 옮길 때 주로 체크해야 할 것들.
- Compose 호환성:
docker compose파일이 그대로 Podman에서 돌아가지 않을 수 있다.depends_on.condition,healthcheck,profiles등 최신 기능은 도구 버전에 따라 다르다 - Docker 소켓 의존: CI runner나 관측 도구가
/var/run/docker.sock에 의존한다면, Podman의podman.sock또는 containerd의containerd.sock으로 바꿔야 한다 - 사용자 네임스페이스 매핑: rootless 모드에서는 UID 매핑이 달라서, 볼륨 권한 이슈가 새로 생길 수 있다
- 빌드 명령:
docker build가 BuildKit 기반인 것처럼, Podman의buildah, nerdctl의 BuildKit 통합도 비슷하지만 완벽 호환은 아니다 - 이미지는 호환: 위의 그림대로 이미지 자체는 OCI 표준을 따르니 그대로 쓸 수 있다. 가장 큰 걱정거리는 아니다
Docker 시리즈 마무리
여기까지 13편의 여정이다. 1편에서 컨테이너가 뭔지부터 시작해, 이미지/네트워크/볼륨의 기본, Dockerfile, Compose, 보안과 최적화, 프로덕션 운영까지 훑었다. 각 편은 다음 편의 전제를 깐다. 7편 Compose에서 쓰인 healthcheck는 12편에서 probe로 확장되고, 10편 보안의 시크릿은 11편 BuildKit에서 --mount=type=secret로 구현된다.
Docker는 도구 하나가 아니라 컨테이너 생태계의 입구다. 여기서 익힌 감각은 Kubernetes, Podman, containerd 어디로 가도 통한다. 이미지와 네임스페이스, 제어 그룹과 런타임 — 같은 재료를 다루는 다른 포장이다.
시리즈의 다음 여정은 각자의 필요에 달려있다. 오케스트레이션이 필요하면 Kubernetes, 보안 중심이라면 Podman과 이미지 서명, 빌드 최적화라면 BuildKit의 더 깊은 사용법(원격 빌더, 분산 캐시 등). 어느 쪽으로 가든 13편에서 쌓은 토대가 배경이 될 것이다.
Docker 시리즈를 처음부터 복습하려면 1편으로 돌아간다. 컨테이너가 왜 생겼는지부터 다시 읽어보면, 지금까지 쌓은 조각들이 어떻게 맞물리는지 새로 보일지도 모른다.

Loading comments...