Skip to content
ioob.dev
Go back

Docker 13편 — 트러블슈팅과 대안

· 10분 읽기
Docker 시리즈 (13/13)
  1. Docker 입문 1편 — Docker란
  2. Docker 입문 2편 — 이미지와 레이어
  3. Docker 입문 3편 — Dockerfile 작성법
  4. Docker 입문 4편 — 컨테이너 생명주기
  5. Docker 입문 5편 — 볼륨과 데이터 영속성
  6. Docker 입문 6편 — 네트워크
  7. Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
  8. Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트
  9. Docker 9편 — 레지스트리, 이미지는 어디에 두는가
  10. Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
  11. Docker 11편 — BuildKit과 고급 빌드
  12. Docker 12편 — 프로덕션 모범 사례
  13. Docker 13편 — 트러블슈팅과 대안
Table of contents

Table of contents

문제 진단의 큰 그림

사고가 발생했을 때 어디부터 봐야 하는지 흐름으로 정리한다.

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앱 레벨 오류스택트레이스 확인
125Docker 명령 자체 실패옵션/이미지 이름 오타
126컨테이너 내부 명령 실행 불가권한 부족, 실행 비트 누락
127명령을 찾을 수 없음PATH 이슈, 바이너리 부재
137SIGKILL로 종료OOM killer 또는 liveness 실패
139SIGSEGV네이티브 크래시
143SIGTERM으로 종료오케스트레이터 정상 종료 경로

가장 오해가 많은 게 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

문제를 빠르게 좁히는 필드들.

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가 다르다.

해결:

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으로 많은 경우 그냥 된다), 두 가지가 다르다.

  1. 데몬이 없다: Docker는 dockerd라는 상시 데몬이 있고 CLI가 이걸 호출하는 구조다. Podman은 각 명령이 직접 컨테이너를 기동한다. SystemD와 친하다(podman generate systemd).
  2. 루트리스가 기본: 일반 사용자로 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을 고르는가:

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 ps
crictl logs <컨테이너 ID>
crictl exec -it <컨테이너 ID> sh

CRI-O

CRI-O는 Kubernetes 전용 런타임이다. OpenShift가 기본으로 쓴다. Kubernetes에 없는 기능은 일부러 안 넣는다는 철학이라 단순하고 가볍다. 일반 개발자가 직접 쓸 일은 거의 없고, 플랫폼 팀 범주다.

마이그레이션 시 고려사항

Docker에서 Podman 또는 containerd 기반으로 옮길 때 주로 체크해야 할 것들.

  1. Compose 호환성: docker compose 파일이 그대로 Podman에서 돌아가지 않을 수 있다. depends_on.condition, healthcheck, profiles 등 최신 기능은 도구 버전에 따라 다르다
  2. Docker 소켓 의존: CI runner나 관측 도구가 /var/run/docker.sock에 의존한다면, Podman의 podman.sock 또는 containerd의 containerd.sock으로 바꿔야 한다
  3. 사용자 네임스페이스 매핑: rootless 모드에서는 UID 매핑이 달라서, 볼륨 권한 이슈가 새로 생길 수 있다
  4. 빌드 명령: docker build가 BuildKit 기반인 것처럼, Podman의 buildah, nerdctl의 BuildKit 통합도 비슷하지만 완벽 호환은 아니다
  5. 이미지는 호환: 위의 그림대로 이미지 자체는 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편으로 돌아간다. 컨테이너가 왜 생겼는지부터 다시 읽어보면, 지금까지 쌓은 조각들이 어떻게 맞물리는지 새로 보일지도 모른다.


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 12편 — 프로덕션 모범 사례
Next Post
Kubernetes 입문 1편 — Kubernetes란