컨테이너 환경에서 처음 만나는 문제들
신규 입사자가 첫 배포를 시도하자마자 port is already allocated, permission denied, Exited (137)이 연달아 발생합니다.
슬랙에는 같은 질문이 반복되고, 멘토는 매번 처음부터 로그 읽는 법을 다시 설명합니다.
문제는 Docker를 "명령어 모음"으로만 배워서 에러를 패턴으로 해석하지 못하는 데 있습니다.
이 모듈은 첫 주에 실제로 마주치는 실패를 재현하고, 진단 흐름을 몸에 익히도록 설계되었습니다.
팀 슬랙 #docker-help 채널에 같은 에러가 반복해서 올라옵니다. "포트 이미 사용 중인데 어떻게 하나요?", "볼륨 마운트했는데 permission denied가 납니다", "컨테이너가 실행하자마자 바로 꺼져요". 에러 메시지는 똑같지만 보내는 사람은 매번 다릅니다. Docker를 처음 쓰는 사람이라면 누구나 한 번씩은 부딪히는 문제들입니다. 이 챕터는 도구 사용법이 아닌 에러 패턴 → 원인 → 해결 흐름으로, 신규 팀원 온보딩 첫 주에 마주치는 대표적인 5가지 에러를 체계적으로 다룹니다. 에러 메시지를 읽는 법과 docker inspect, docker logs, docker events로 원인을 추적하는 습관을 함께 익힙니다.
컨테이너 입문자가 첫 주에 반드시 마주치는 에러 패턴들을 직접 재현하고 진단 → 원인 → 해결 흐름으로 풀어냅니다. 에러 메시지를 두려워하지 않고, 진단 명령으로 원인을 좁혀가는 사고 방식을 익히는 것이 목표입니다.
- 1포트 충돌 시 ss, lsof, docker ps로 `port is already allocated`를 진단하고 해결할 수 있다
- 2볼륨 UID/GID 불일치로 인한 `permission denied`의 원인을 추적하고 chown/chmod로 해결할 수 있다
- 3CMD와 ENTRYPOINT의 포그라운드 실행 원칙을 적용해 컨테이너 즉시 종료를 해결할 수 있다
- 4Alpine과 Debian의 셸 차이로 생기는 `exec /bin/sh: no such file or directory`를 해결할 수 있다
- 5OOMKilled 상황에서 메모리 제한 초과를 감지하고 limits를 조정할 수 있다
- 6빌드 캐시가 적용되지 않을 때 COPY 순서를 최적화하고 .dockerignore 누락 문제를 해결할 수 있다
실습은 순서대로 진행합니다. 각 실습은 에러를 의도적으로 재현한 뒤 진단 → 해결 순서로 따라갑니다. 실습 환경은 Linux 호스트 또는 Docker Desktop(Mac/Windows) 모두 동작합니다.
docker --versionmkdir -p ~/docker-debug-lab && cd ~/docker-debug-labdocker run -d --name port-occupier -p 8080:80 nginx:alpinemkdir -p ~/docker-debug-lab/data && echo 'hello' > ~/docker-debug-lab/data/test.txtdocker rm -f port-occupier crash-test oom-test && rm -rf ~/docker-debug-lab
증상을 4갈래로 나누고 도구를 고르는 법 — 첫 3분 라우팅
에러 메시지가 명확할 때(port is already allocated)는 다음 할 일이 뻔하지만, "컨테이너가 이상하다"처럼 증상만 있을 때는 어느 명령부터 칠지가 막막합니다. 첫 3분을 아끼는 방법은 증상을 네 갈래 중 하나로 먼저 분류하고, 그 갈래에 맞는 도구를 고르는 것입니다 — logs·inspect·stats·exec은 아무 때나 쓰는 게 아니라 증상마다 답을 주는 게 다릅니다.
컨테이너가 이상하다 — 먼저 증상을 4갈래 중 하나로 분류
│
├─ (A) 아예 안 뜸 docker ps 에 없음 / 즉시 Exited
│ → docker ps -a (ExitCode) → docker logs → inspect .State.Error
│
├─ (B) 떴다가 죽음 Up 몇 초 → Exited / Restarting 반복
│ → docker logs (마지막 말) → inspect .State.OOMKilled · RestartCount
│
├─ (C) 떠 있는데 느림 Up 인데 응답 지연 · 타임아웃
│ → docker stats (CPU · MEM 실시간) → docker top (어느 프로세스)
│
└─ (D) 떠 있는데 연결 안 됨 Up 인데 curl 실패 · 502
→ docker port · inspect (포트 매핑) → exec 후 내부 localhost 확인
▼
도구가 가리킨 곳에서 "앱 오류냐 인프라(포트 · 메모리 · 마운트)냐"를 로그로 가른다
증상별로 먼저 고르는 도구와, 그 도구가 답하는 질문:
| 증상 | 먼저 고르는 도구 | 그 도구가 답하는 질문 |
|---|---|---|
| (A) 아예 안 뜸 | docker ps -a → docker logs → docker inspect | ExitCode 가 몇인가(127=명령 없음 · 1=앱 오류) · 로그가 있나 없나 · .State.Error 에 런타임 메시지가 있나 |
| (B) 떴다 죽음 | docker logs --tail → docker inspect .State | 종료 직전 앱이 남긴 예외가 있나 · OOMKilled=true 인가(메모리) · RestartCount 가 늘고 있나(크래시 루프) |
| (C) 떠 있는데 느림 | docker stats → docker top | CPU · 메모리가 제한에 붙었나 · 어느 프로세스가 자원을 먹나. 이건 로그만 봐선 안 보인다 |
| (D) 연결 안 됨 | docker port · docker inspect → docker exec | 호스트 포트가 컨테이너 포트로 매핑됐나 · 컨테이너 안에서 앱이 실제로 그 포트를 LISTEN 하나 |
네 갈래 모두 마지막은 같은 질문으로 모입니다 — 앱이 스스로 잘못됐나(로그의 예외 · 스택트레이스), 아니면 앱을 감싼 인프라가 잘못됐나(포트 매핑 · 메모리 제한 · 볼륨 권한). 로그에 앱 에러가 찍혀 있으면 앱 문제, 로그는 멀쩡한데 뜨지도 · 붙지도 않으면 인프라 문제 쪽입니다. 원인을 좁혔으면 같은 docker run 옵션으로 한 번 더 재현해 가설을 확인하고 넘어가세요. 이 라우팅은 아래에서 하나씩 다룰 개별 에러(포트 충돌 · 권한 · 즉시 종료 · OOM)를 "어디서부터 볼지"로 묶어 주는 지도입니다.
포트 충돌 — port is already allocated 진단과 해결
nginx 컨테이너를 8080 포트로 띄우려는데 이런 에러가 납니다:
docker: Error response from daemon: driver failed programming external
connectivity on endpoint myapp (...):
Bind for 0.0.0.0:8080 failed: port is already allocated.
에러 메시지는 명확합니다. 호스트의 8080 포트가 이미 점유되어 있습니다. 그런데 무엇이 쓰고 있는지 모릅니다. 이 시점에서 docker inspect나 docker events보다 먼저 쓸 도구는 포트 점유 프로세스를 보여주는 명령입니다.
확대
진단 흐름
1단계: 호스트 포트 점유 프로세스 확인
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part4/exam_15 && cd /tmp/docker/part4/exam_15
# 방법 1: ss (modern, 대부분 Linux 기본 탑재)
ss -tlnp | grep 8080
# State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
# LISTEN 0 511 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=12345,fd=4))
# 방법 2: lsof (macOS/Linux 공통)
lsof -i :8080
# COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
# docker-pr 12345 root 4u IPv4 ... 0t0 TCP *:http-alt (LISTEN)
# 방법 3: netstat (구형 시스템)
netstat -tlnp | grep 8080
docker-proxy가 보이면 다른 컨테이너가 8080을 이미 바인딩하고 있는 것입니다.
2단계: 점유 중인 컨테이너 찾기
# 8080을 바인딩 중인 컨테이너 확인
docker ps --format "table {{.Names}}\t{{.Ports}}" | grep 8080
# NAMES PORTS
# port-occupier 0.0.0.0:8080->80/tcp
# 또는 더 상세하게 (컨테이너 ID까지)
docker ps --filter "publish=8080"
3단계: 해결 선택지
# 선택 1: 기존 컨테이너 중지 후 새로 실행
docker stop port-occupier
docker run -d --name myapp -p 8080:80 nginx:alpine
# 선택 2: 다른 호스트 포트 사용 (기존 컨테이너 유지)
docker run -d --name myapp -p 8081:80 nginx:alpine
# ^^^^ 충돌 없는 포트로 변경
# 선택 3: docker events로 실시간 충돌 모니터링
docker events --filter 'type=container' --filter 'event=die' &
docker run -d --name myapp -p 8080:80 nginx:alpine
# 재시도 중에 어떤 이벤트가 발생하는지 확인
docker inspect로 바인딩 정보 확인
컨테이너가 실행 중이라면 inspect로 포트 바인딩 상세 정보를 확인할 수 있습니다:
docker inspect port-occupier --format \
'{{range $port, $bindings := .NetworkSettings.Ports}}{{$port}} -> {{range $bindings}}{{.HostIp}}:{{.HostPort}}{{end}}{{"\n"}}{{end}}'
# 80/tcp -> 0.0.0.0:8080
핵심 원칙
포트 충돌은 에러 메시지가 명확하게 원인을 알려줍니다. ss -tlnp | grep <포트> → 점유 프로세스 확인 → 종료하거나 다른 포트 사용. 이 3단계 패턴을 습관으로 만들면 됩니다.
볼륨 마운트 후 컨테이너 내부에서 파일을 읽거나 쓸 때 permission denied가 나는 가장 흔한 원인은 UID/GID 불일치입니다. 같은 숫자여야 같은 사람입니다 — 컨테이너 내부와 호스트가 이름이 아닌 숫자(UID)로 파일 소유권을 판단하기 때문입니다. 먼저 root 700 디렉터리를 만들어 문제를 재현합니다.
# 호스트에서 root 소유 디렉토리 생성 (권한 700)
sudo mkdir -p /tmp/strict-data
sudo chmod 700 /tmp/strict-data
sudo bash -c 'echo "secret" > /tmp/strict-data/config.txt'
# nginx는 내부적으로 UID 101(nginx 사용자)로 실행됩니다
docker run --rm \
-v /tmp/strict-data:/data \
nginx:alpine \
cat /data/config.txt
# cat: can't open '/data/config.txt': Permission denied
docker run --rm -v /tmp/strict-data:/data nginx:alpine cat /data/config.txt- 재현 단계에서 'Permission denied'가 떴는지 먼저 확인 — 떠야 정상이다(700 + UID 불일치 재현 성공). 바로 secret이 출력되면 디렉터리 권한이 이미 열려 있는 것
- ls -la /tmp/strict-data 의 소유자가 root(UID 0)이고 권한이 drwx------ 인지 본다 — 컨테이너 프로세스 UID가 0이 아니면 접근 불가가 당연하다
- docker run nginx:alpine id 로 컨테이너 실제 UID를 확인 — 이 숫자와 호스트 디렉터리 소유 UID가 같아야 읽힌다(이름이 아니라 숫자가 기준)
- 해결 후(chown 101 또는 --user) 'secret'이 출력되는지로 성공을 판정한다
진단: 누가 누구인지 확인
# 1. 호스트 디렉토리의 소유자 확인
ls -la /tmp/strict-data/
# drwx------ 2 root root 4096 ... ← UID 0 (root)만 접근 가능
# 2. 컨테이너 내부 실행 프로세스의 UID 확인
docker run --rm nginx:alpine id
# uid=0(root) gid=0(root) groups=0(root) ← nginx 마스터는 root
docker run --rm nginx:alpine \
sh -c "ps aux | grep nginx"
# 1 root nginx: master process ... ← PID 1은 root
# 6 nginx nginx: worker process ← 워커는 UID 101(nginx)
# 3. docker inspect로 마운트 확인
docker inspect <중지된-컨테이너-ID> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} ({{.Mode}}){{"\n"}}{{end}}'
해결 방법
# 방법 1: 호스트 디렉토리 권한을 컨테이너 UID에 맞게 변경
# nginx 워커의 UID는 101
sudo chown -R 101:101 /tmp/strict-data
sudo chmod 755 /tmp/strict-data
docker run --rm \
-v /tmp/strict-data:/data \
nginx:alpine \
cat /data/config.txt
# secret ← 정상 출력
# 방법 2: 컨테이너 실행 시 --user로 UID 맞추기
# 호스트 현재 사용자 UID로 컨테이너 실행
docker run --rm \
--user $(id -u):$(id -g) \
-v ~/docker-debug-lab/data:/data \
alpine \
cat /data/test.txt
# hello ← 정상 출력
# 방법 3: Dockerfile에서 소유권 설정 (이미지 빌드 단계)
# COPY --chown=nginx:nginx ./config /etc/nginx/conf.d/
# RUN chown -R nginx:nginx /var/cache/nginx
docker events로 실패 이벤트 추적
# 터미널 1: 이벤트 스트림 켜기
docker events --filter 'type=container' &
# 터미널 2: 실패하는 컨테이너 실행
docker run --rm -v /tmp/strict-data:/data alpine cat /data/config.txt
# events 출력에서 start → die (exit code 1) 패턴 확인
# 컨테이너가 정상 종료인지 오류 종료인지 구분할 수 있습니다
컨테이너 즉시 종료 — CMD vs ENTRYPOINT 포그라운드 실행 원칙
컨테이너를 띄웠는데 docker ps에 보이지 않습니다. docker ps -a로 보면 Exited(0) 또는 Exited(1) 상태입니다. docker logs를 봐도 아무 출력이 없거나, 출력은 있는데 곧바로 꺼집니다.
이 패턴의 핵심은 하나입니다: 컨테이너는 PID 1 프로세스가 종료되면 함께 종료됩니다.
확대
즉시 종료가 발생하는 대표 케이스
케이스 1: 백그라운드 데몬 실행 명령
# 잘못된 예시 — service, systemctl은 데몬을 백그라운드로 띄우고 즉시 반환
CMD ["service", "nginx", "start"]
# 또는
CMD ["systemctl", "start", "nginx"]
# 또는
CMD ["/etc/init.d/nginx", "start"]
# 재현
docker run --name crash-test nginx:alpine sh -c "nginx && echo done"
# done 출력 후 즉시 종료
docker ps -a | grep crash-test
# crash-test nginx:alpine "sh -c 'nginx && ..." 3 sec ago Exited (0)
docker logs crash-test
# done ← 정상 출력처럼 보이지만 컨테이너는 이미 종료됨
# 올바른 예시 — 포그라운드 실행
CMD ["nginx", "-g", "daemon off;"]
케이스 2: 셸 형식 vs exec 형식 ENTRYPOINT
# 셸 형식 (shell form) — /bin/sh -c 를 통해 실행
ENTRYPOINT "nginx -g 'daemon off;'"
# exec 형식 (exec form, 권장) — 직접 실행, PID 1이 됨
ENTRYPOINT ["nginx", "-g", "daemon off;"]
셸 형식은 /bin/sh -c가 PID 1이 됩니다. Alpine처럼 /bin/sh가 없는 환경에서는 즉시 실패합니다. exec 형식은 지정한 바이너리가 PID 1이 됩니다.
docker inspect로 종료 원인 추적
# 컨테이너 종료 후 상세 상태 확인
docker inspect crash-test --format '
ExitCode: {{.State.ExitCode}}
OOMKilled: {{.State.OOMKilled}}
Error: {{.State.Error}}
StartedAt: {{.State.StartedAt}}
FinishedAt: {{.State.FinishedAt}}'
# ExitCode: 0 → 정상 종료 (CMD가 끝남)
# ExitCode: 1 → 오류 종료 (CMD 실행 실패)
# ExitCode: 137 → SIGKILL (OOM 또는 강제 종료)
# ExitCode: 139 → Segmentation fault
docker events로 라이프사이클 이벤트 확인
# 컨테이너 시작과 종료 이벤트를 시간순으로 확인
docker events \
--since $(date -d '5 minutes ago' -u +%Y-%m-%dT%H:%M:%SZ) \
--filter 'type=container' \
--filter 'event=start' \
--filter 'event=die' \
--format '{{.Time}} {{.Action}} {{.Actor.Attributes.name}} exitCode={{.Actor.Attributes.exitCode}}'
# 1748540000 start crash-test exitCode=
# 1748540001 die crash-test exitCode=0 ← 1초 만에 종료
포그라운드 실행 확인 방법
# 올바르게 실행된 컨테이너 확인
docker run -d --name fg-test nginx:alpine
docker inspect fg-test --format '{{.State.Status}}'
# running ← 계속 실행 중
# PID 1 확인
docker exec fg-test cat /proc/1/cmdline | tr '\0' ' '
# nginx: master process nginx -g daemon off; ← PID 1이 nginx 자체
실습 2: Alpine vs Debian 셸 차이 — exec /bin/sh: no such file or directory
이미지를 바꿨더니 갑자기 컨테이너가 뜨지 않습니다. Dockerfile에서 Alpine 이미지를 사용하는데 셸 스크립트 기반 ENTRYPOINT가 실패합니다.
문제 재현
# 셸 형식 CMD — /bin/sh 가 없으면 실패
docker run --rm alpine:3.18 sh -c "echo hello"
# hello ← Alpine에는 /bin/sh 있음 (busybox ash가 /bin/sh로 링크됨)
# 그런데 특정 최소화 이미지나 scratch 기반은 다릅니다
# 다음 예시: 비표준 이미지에서 셸 경로 문제 재현
cat > /tmp/test-entrypoint.sh << 'EOF'
#!/bin/bash
echo "Starting application..."
exec "$@"
EOF
chmod +x /tmp/test-entrypoint.sh
# Alpine에서 bash shebang 스크립트 실행 시 실패
docker run --rm \
-v /tmp/test-entrypoint.sh:/entrypoint.sh \
alpine:3.18 \
/entrypoint.sh echo "app started"
# /entrypoint.sh: line 1: bash: not found
진단
# Alpine과 Debian의 셸 차이 비교
docker run --rm alpine:3.18 ls -la /bin/sh /bin/bash 2>&1
# lrwxrwxrwx 1 root root 12 ... /bin/sh -> /bin/busybox ← sh만 있음
# ls: /bin/bash: No such file or directory ← bash 없음
docker run --rm debian:bookworm-slim ls -la /bin/sh /bin/bash 2>&1
# lrwxrwxrwx 1 root root 4 ... /bin/sh -> dash
# -rwxr-xr-x 1 root root ... /bin/bash ← bash 있음
# 컨테이너가 실패했다면 docker inspect로 에러 확인
docker run --name shell-test \
alpine:3.18 /entrypoint.sh echo "test" 2>/dev/null; true
docker inspect shell-test --format '{{.State.ExitCode}} {{.State.Error}}'
docker rm shell-test
해결 방법
# 방법 1: shebang을 #!/bin/sh 로 변경 (POSIX sh, Alpine 호환)
cat > /tmp/test-entrypoint-fixed.sh << 'EOF'
#!/bin/sh
echo "Starting application..."
exec "$@"
EOF
chmod +x /tmp/test-entrypoint-fixed.sh
docker run --rm \
-v /tmp/test-entrypoint-fixed.sh:/entrypoint.sh \
alpine:3.18 \
/entrypoint.sh echo "app started"
# Starting application...
# app started
# 방법 2: Alpine에 bash 설치 (Dockerfile)
# RUN apk add --no-cache bash
# 방법 3: Debian 계열 이미지 사용 (bash가 필요하다면)
# FROM debian:bookworm-slim 또는 ubuntu:22.04
exec /bin/sh: no such file or directory 가 나는 경우
이 에러는 이미지 자체에 /bin/sh가 없는 경우입니다 (scratch, distroless 기반):
# docker inspect로 이미지 정보 확인
docker inspect nginx:alpine --format '{{.Config.Cmd}}'
# [nginx -g daemon off;] ← exec 형식, /bin/sh 불필요
# docker events로 즉시 종료 이벤트 확인
docker events --filter 'event=die' --format '{{.Actor.Attributes.name}} {{.Actor.Attributes.exitCode}}' &
docker run --name no-shell gcr.io/distroless/static:latest /bin/sh 2>/dev/null; true
# no-shell 127 ← exitCode 127 = command not found
OOMKilled — 메모리 제한 초과 감지와 limits 조정
컨테이너가 갑자기 종료되고 docker ps -a에 Exited(137)이 보입니다. 137은 128 + SIGKILL(9)입니다. 커널이 메모리 부족으로 프로세스에 SIGKILL을 보낸 것입니다. OOMKilled입니다.
확대
진단 흐름
1단계: OOMKilled 여부 확인
# 가장 먼저: docker inspect로 OOMKilled 플래그 확인
docker inspect <컨테이너명> --format \
'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'
# OOMKilled=true ExitCode=137 ← 메모리 부족으로 커널이 종료함
# 전체 컨테이너 중 OOMKilled된 것 찾기
docker ps -a --format '{{.Names}} {{.Status}}' | while read name status; do
if docker inspect "$name" --format '{{.State.OOMKilled}}' 2>/dev/null | grep -q true; then
echo "OOMKilled: $name ($status)"
fi
done
2단계: 메모리 설정과 사용량 확인
# 현재 메모리 제한 확인
docker inspect <컨테이너명> --format \
'MemoryLimit={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}}'
# MemoryLimit=104857600 MemorySwap=209715200 ← 100MB 제한
# 실행 중인 컨테이너의 실시간 메모리 사용량
docker stats --no-stream <컨테이너명>
# CONTAINER CPU % MEM USAGE / LIMIT MEM %
# myapp 45.2% 98MiB / 100MiB 98.0% ← 제한에 근접
# 호스트 커널 OOM 로그 확인
dmesg | grep -i "oom\|killed" | tail -20
# [12345.678] oom-kill:constraint=CONSTRAINT_MEMCG,...
# [12345.679] oom_reaper: reaped process ... (myapp), now anon-rss:0kB
3단계: 메모리 제한 조정
# 실행 중인 컨테이너 메모리 제한 즉시 변경 (임시 조치)
docker update --memory 512m --memory-swap 768m <컨테이너명>
# 변경 확인
docker inspect <컨테이너명> --format '{{.HostConfig.Memory}}'
# 536870912 ← 512MB (512 * 1024 * 1024)
4단계: 근본 원인 파악
OOMKilled는 증상이지 원인이 아닙니다. 메모리 누수인지, 제한이 너무 낮은지 파악해야 합니다:
# 컨테이너 시작 직후부터 메모리 추이 관찰
docker stats <컨테이너명>
# 메모리가 계속 증가 → 누수 가능성
# 메모리가 일정 수준 유지 → 제한이 너무 낮은 것
# docker logs로 OOM 직전 로그 확인
docker logs --tail 100 <컨테이너명> 2>&1 | grep -iE "memory|heap|gc|error"
docker-compose에서 메모리 제한 설정
services:
myapp:
image: myapp:latest
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
# 또는 (Compose v2 단독 실행 시)
mem_limit: 512m
memswap_limit: 768m
실습 3: 빌드 캐시 미적용 — COPY 순서와 .dockerignore 누락
코드를 한 줄 수정했는데 docker build가 처음부터 전부 다시 빌드됩니다. 의존성 설치에만 2분이 걸립니다. 반대로 .dockerignore를 빠뜨려서 빌드 컨텍스트에 node_modules가 통째로 올라가 빌드가 10분씩 걸리기도 합니다.
캐시 미적용 문제 재현과 진단
mkdir -p ~/docker-debug-lab/cache-test
cd ~/docker-debug-lab/cache-test
# 잘못된 Dockerfile — COPY 순서 문제
cat > Dockerfile.bad << 'EOF'
FROM node:18-alpine
WORKDIR /app
# 소스 코드를 먼저 복사 — 코드 한 줄 바꾸면 아래 npm install도 재실행
COPY . .
RUN npm install # ← 캐시 미적용: 코드 변경 시마다 2분 소요
CMD ["node", "index.js"]
EOF
# package.json 생성
cat > package.json << 'EOF'
{
"name": "cache-test",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.0"
}
}
EOF
echo "console.log('hello')" > index.js
# 첫 빌드 (캐시 없음)
docker build -f Dockerfile.bad -t cache-test:bad . --progress=plain 2>&1 | grep -E "^#[0-9]|CACHED|RUN"
올바른 Dockerfile — 의존성 레이어 분리
cat > Dockerfile.good << 'EOF'
FROM node:18-alpine
WORKDIR /app
# 1. 의존성 파일만 먼저 복사 — package.json이 안 바뀌면 npm install 캐시 사용
COPY package*.json ./
RUN npm install # ← package.json 변경 시에만 재실행
# 2. 소스 코드는 의존성 설치 후 복사
COPY . .
CMD ["node", "index.js"]
EOF
# 첫 빌드
docker build -f Dockerfile.good -t cache-test:good . --progress=plain
# index.js 수정 후 재빌드 — npm install은 CACHED 처리됨
echo "console.log('world')" > index.js
docker build -f Dockerfile.good -t cache-test:good . --progress=plain 2>&1 | grep -E "CACHED|RUN npm"
# #5 CACHED ← npm install 캐시 적용!
.dockerignore 누락 문제
# node_modules 없는 상태에서 빌드 컨텍스트 크기 확인
du -sh .
# 4.0K ← 작음
# node_modules 설치 후 크기 변화
npm install 2>/dev/null
du -sh .
# 28M ← node_modules 포함
# .dockerignore 없이 빌드 시 컨텍스트 전송 크기 확인
docker build -f Dockerfile.good -t cache-test:context . 2>&1 | grep "Sending build context"
# Sending build context to Docker daemon 28.5MB ← 느림!
# .dockerignore 작성
cat > .dockerignore << 'EOF'
node_modules
.git
*.log
.env
.DS_Store
coverage
.nyc_output
EOF
# 다시 빌드 — 컨텍스트 크기 비교
docker build -f Dockerfile.good -t cache-test:context . 2>&1 | grep "Sending build context"
# Sending build context to Docker daemon 3.584kB ← 1000배 작아짐
캐시 적용 여부 확인
# docker build history로 각 레이어 빌드 시간 확인
docker history cache-test:good --no-trunc --format "table {{.CreatedBy}}\t{{.Size}}"
# BuildKit 상세 로그로 CACHED/RUN 구분
DOCKER_BUILDKIT=1 docker build -f Dockerfile.good -t cache-test:good . \
--progress=plain 2>&1 | grep -E "^#[0-9]+.*CACHED|^#[0-9]+.*RUN"
- port 충돌/권한/OOM 사례를 에러 문구만 보고도 어떤 진단 명령부터 칠지 판단할 수 있는가?
- `docker inspect`에서 상태(State), 메모리 제한(HostConfig.Memory), 마운트(Mounts) 값을 직접 추출해 원인을 좁혔는가?
- 동일 에러를 만났을 때 "재시작"보다 "원인 확인"을 먼저 하는 흐름을 설명할 수 있는가?
문제 상황
$ docker ps -a
CONTAINER ID IMAGE STATUS NAMES
a1b2c3d4e5f6 myapp:1.0 Exited (137) 3 minutes ago myapp
$ docker logs myapp
... (정상 로그들)
... (갑자기 끊김, 종료 메시지 없음)
원인 확인
# OOMKilled 플래그와 종료 코드 확인
docker inspect myapp --format \
'Status={{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'
# Status=exited OOMKilled=true ExitCode=137
# 설정된 메모리 제한 확인
docker inspect myapp --format \
'MemLimit={{.HostConfig.Memory}}'
# MemLimit=104857600 (= 100MB)
# 호스트 커널 OOM 이벤트 확인
sudo dmesg | grep -i oom | tail -5
해결 방법
# 1. 즉시 조치: 메모리 제한 증가
docker update --memory 512m --memory-swap 1g myapp
docker start myapp
# 2. 메모리 사용 추이 모니터링
docker stats myapp
# 3. 근본 원인 파악: 메모리가 계속 증가하면 누수, 일정하면 제한 부족
# 메모리 누수 의심 시 → 애플리케이션 코드 점검 (캐시 무제한 성장, 커넥션 풀 누수 등)
# 제한만 낮은 경우 → docker-compose.yml의 mem_limit 또는 deploy.resources.limits.memory 조정
# 4. 재발 방지: docker-compose.yml 업데이트
# deploy:
# resources:
# limits:
# memory: 512M
문제 상황
$ docker ps
Cannot connect to the Docker daemon at unix:///var/run/docker.sock.
Is the docker daemon running?
# 또는
$ docker ps
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/json":
dial unix /var/run/docker.sock: connect: permission denied
원인 분류와 해결
# 원인 1: Docker 데몬이 실행 중이지 않음
sudo systemctl status docker
# ● docker.service - Docker Application Container Engine
# Loaded: loaded (/lib/systemd/system/docker.service; enabled)
# Active: inactive (dead) ← 꺼져 있음
sudo systemctl start docker
sudo systemctl enable docker # 부팅 시 자동 시작
# 원인 2: 현재 사용자가 docker 그룹에 없음 (sudo 없이 docker 실행 시)
groups
# user sudo ← docker 그룹 없음
sudo usermod -aG docker $USER
newgrp docker # 현재 세션에 즉시 적용 (재로그인 없이)
# 또는 재로그인 후
groups
# user sudo docker ← docker 그룹 추가됨
# 원인 3: 소켓 파일 권한 문제
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker ... ← docker 그룹에 rw 권한
# docker 그룹 확인
getent group docker
# docker:x:998:user1,user2
# 원인 4: DOCKER_HOST 환경 변수가 잘못 설정된 경우
echo $DOCKER_HOST
# tcp://wrong-host:2375 ← 잘못된 원격 호스트
unset DOCKER_HOST # 초기화하여 로컬 소켓 사용
docker events로 데몬 연결 확인
# 데몬 연결 후 이벤트 스트림이 정상적으로 흐르는지 확인
docker events --since 1m --until now
# 정상이면 최근 1분 이벤트 출력 (또는 빈 출력)
# 연결 실패면 에러 메시지
심화 — 볼륨은 이미지의 파일을 '가린다'
심화: 마운트는 이미지 파일 위에 얹힌다 — 그리고 그 아래를 가린다
권한 문제(UID 불일치)는 "파일은 보이는데 못 읽는" 경우입니다. 그런데 한 단계 더 흔한 함정은 "-v를 붙였더니 파일 자체가 사라진" 경우인데, 원인이 권한이 아니라 마운트의 동작 방식에 있습니다.
볼륨과 바인드 마운트는 마운트 지점 위에 얹히는 오버레이입니다. 그 지점에 이미지가 구워둔 파일은 마운트에 가려 더 이상 보이지 않습니다 — 삭제된 것이 아니라 덮인 것입니다.
- 바인드 마운트(
-v /host/path:/target): 호스트 경로가 그대로 얹힙니다. 호스트 경로가 비어 있으면 대상의 이미지 파일이 통째로 사라진 듯 보이고, 호스트 경로가 아예 없으면 Docker가 빈 root 소유 디렉터리를 새로 만들어 얹습니다(그래서 상대경로 오타가 그대로 빈 마운트가 됩니다). - 명명된 볼륨(
-v myvol:/target): 그 볼륨이 처음 마운트될 때 비어 있으면, Docker가 이미지의 원래 내용을 볼륨으로 복사해 초기화합니다. 바인드 마운트에는 이 초기화가 없습니다 — 같은-v문법이라도 둘의 첫 동작이 다릅니다.
그래서 "볼륨 없이는 되는데 -v를 붙이면 config not found / 403"은 대개 권한이 아니라 마운트가 이미지 파일을 가린 것입니다. 진단은 간단합니다: 마운트를 뺀 채 이미지의 그 경로를 ls해 파일이 원래 있었는지 확인하고, 마운트한 상태로 같은 경로를 ls해 호스트 쪽 내용만 보이는지 대조하면 됩니다.
상황: nginx 이미지에는 기본 index.html이 들어 있어 -v 없이는 페이지가 잘 뜹니다. 정적 파일을 갈아끼우려 호스트의 ./html을 마운트했더니 페이지가 사라지고 로그에는 directory index of "/usr/share/nginx/html/" is forbidden만 찍힙니다. UID 권한 문제로 오해해 chmod 777까지 해봐도 그대로입니다.
원인: 바인드 마운트가 이미지의 /usr/share/nginx/html(원래 index.html 포함)을 호스트 ./html로 가렸는데, 정작 호스트 ./html이 비어 있(거나 index.html이 없)어서 nginx가 인덱스 파일을 못 찾은 것입니다. 파일이 지워진 게 아니라 이미지 파일이 마운트에 덮였을 뿐입니다. 상대경로 ./html이 오타이거나 실행 위치가 달랐다면, Docker가 빈 디렉터리를 새로 만들어 얹어 같은 증상이 납니다.
진단: 먼저 마운트 없이 이미지에 파일이 있었는지 확인합니다 — docker run --rm nginx:alpine ls /usr/share/nginx/html에 index.html이 보이면 이미지는 정상입니다. 다음으로 마운트한 채 docker exec <id> ls /usr/share/nginx/html을 보면 호스트 쪽 내용(비었거나 다른 파일)만 나옵니다. ls ./html로 호스트 경로의 존재·내용을 확인하고, docker inspect --format '{{range .Mounts}}{{.Source}}{{end}}' <id>로 Source가 의도한 절대경로인지 대조합니다.
해결: 호스트 경로에 실제 파일을 채웁니다(index.html 배치). 이미지의 기본 파일을 대부분 유지하며 일부만 바꾸려면 파일 단위 바인드(-v $(pwd)/index.html:/usr/share/nginx/html/index.html)나, 첫 마운트 시 이미지 내용으로 초기화되는 명명 볼륨을 씁니다. 경로 오타로 인한 빈 마운트를 막기 위해 상대경로 대신 절대경로($(pwd)/html)를 지정하는 습관도 함께 들입니다.
배경
스타트업 C사에 DevOps 팀원이 합류했습니다. Docker는 개념만 알고 실무 경험은 없습니다. 온보딩 첫 주, 슬랙 #docker-help 채널에 올라온 질문들과 팀에서 공유한 해결 패턴입니다.
Day 1 — 포트 충돌
# 팀원 질문: "docker run 했는데 이런 에러가..."
$ docker run -d -p 8080:80 nginx:alpine
Error: port is already allocated
# 시니어 답변: 이렇게 찾아요
$ ss -tlnp | grep 8080
LISTEN 0 128 0.0.0.0:8080 users:(("docker-proxy",pid=4521))
$ docker ps --format "{{.Names}}\t{{.Ports}}" | grep 8080
old-nginx 0.0.0.0:8080->80/tcp
$ docker stop old-nginx && docker run -d --name nginx -p 8080:80 nginx:alpine
# 또는
$ docker run -d --name nginx -p 8081:80 nginx:alpine # 포트 변경
Day 2 — 볼륨 권한 문제
# 팀원 질문: "볼륨 연결했는데 permission denied..."
$ docker run -v /home/user/data:/app/data myapp:latest
Error: open /app/data/config.json: permission denied
# 시니어 답변: UID 먼저 확인해요
$ docker inspect myapp:latest --format '{{.Config.User}}'
# 1000 (또는 appuser)
$ ls -la /home/user/data/
drwx------ 2 root root ... ← root 소유, 1000번 사용자 접근 불가
$ sudo chown -R 1000:1000 /home/user/data/
$ docker run -v /home/user/data:/app/data myapp:latest # 정상 실행
Day 3 — 컨테이너 즉시 종료
# 팀원 질문: "docker run 하면 바로 꺼져요..."
$ docker run myapp:latest
$ docker ps
# (아무것도 없음)
$ docker ps -a
myapp Exited(0) 2 sec ago
# 시니어 답변: logs와 inspect 보세요
$ docker logs $(docker ps -lq)
# (로그 있으면 뭔가 실행은 됨, 없으면 CMD 자체가 즉시 종료)
$ docker inspect $(docker ps -lq) --format '{{.Config.Cmd}} {{.Config.Entrypoint}}'
# [service nginx start] [] ← 백그라운드 데몬 실행이 원인
# Dockerfile 수정: CMD ["nginx", "-g", "daemon off;"]
Day 4 — 빌드 캐시 미적용
# 팀원 질문: "코드 한 줄 바꿨는데 왜 2분씩 걸리죠?"
# 원인: COPY . . 가 RUN npm install 위에 있음
# 시니어 답변: Dockerfile 순서 바꾸세요
# Before:
# COPY . .
# RUN npm install
#
# After:
# COPY package*.json ./
# RUN npm install
# COPY . .
# 그리고 .dockerignore 꼭 만들어요
$ cat .dockerignore
node_modules
.git
*.log
Day 5 — OOMKilled
# 팀원 질문: "컨테이너가 계속 죽어요, 로그도 없이..."
$ docker inspect crashed-app --format \
'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'
# OOMKilled=true ExitCode=137
# 시니어 답변: 메모리 제한 먼저 확인해요
$ docker inspect crashed-app --format '{{.HostConfig.Memory}}'
# 67108864 (= 64MB, 너무 낮음)
$ docker update --memory 256m crashed-app
$ docker start crashed-app
# docker-compose.yml도 업데이트해두세요:
# deploy:
# resources:
# limits:
# memory: 256M
팀 온보딩 가이드 — 에러 발생 시 첫 3분 루틴
1. docker logs <컨테이너> → 무슨 에러인지 파악
2. docker inspect <컨테이너> 확인 항목:
- .State.ExitCode (종료 코드)
- .State.OOMKilled (메모리 종료 여부)
- .State.Error (에러 메시지)
- .HostConfig.Memory (메모리 제한)
- .Mounts (볼륨 마운트 확인)
3. docker events --since 5m → 최근 이벤트 흐름 확인
4. ss -tlnp | grep <포트> → 포트 충돌 확인
핵심 요약
이번 챕터에서 다룬 5가지 에러 패턴과 진단 명령 정리입니다.
| 에러 패턴 | 첫 번째 진단 명령 | 핵심 원인 |
|---|---|---|
| port is already allocated | ss -tlnp | grep <포트> | 호스트 포트 이중 바인딩 |
| permission denied (볼륨) | docker exec <id> id | 컨테이너 UID ≠ 호스트 소유자 |
| 컨테이너 즉시 종료 | docker inspect --format '{{.State.ExitCode}}' | PID 1이 백그라운드로 실행됨 |
| exec /bin/sh 실패 | docker run --rm <이미지> ls /bin/sh | Alpine은 bash 없음, sh만 있음 |
| OOMKilled (137) | docker inspect --format '{{.State.OOMKilled}}' | 메모리 제한 초과 |
에러 발생 시 기본 3단계: docker logs → docker inspect → docker events. 이 순서를 습관으로 만드는 것이 이번 챕터의 가장 중요한 목표입니다.
명령어·단축키 빠른 참조
첫 주에 마주치는 5가지 에러를 좁혀 갈 때 이 모듈에서 실제로 쓴 docker 진단 명령을 한자리에 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
docker ps -a | 종료·재시작 포함 전체 컨테이너 상태 확인 | docker ps -a --format "table {{.Names}}\t{{.Status}}" |
docker logs | 종료 직전 stdout/stderr로 1차 원인 파악 | docker logs --tail 100 <c> |
docker inspect -f | 종료 원인 필드만 Go 템플릿으로 추출 | docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}}' <c> |
docker events | 컨테이너 start/die 생명주기 이벤트 관찰 | docker events --filter event=die --format '{{.Actor.Attributes.name}} {{.Actor.Attributes.exitCode}}' |
docker stats | 실시간 CPU·메모리(제한 대비) 스냅샷 | docker stats --no-stream <c> |
docker update | 실행 중 컨테이너의 메모리 제한 즉시 변경 | docker update --memory 512m --memory-swap 1g <c> |
docker exec | 실행 중 컨테이너 내부 UID·파일 확인(권한 진단) | docker exec <c> id |
docker ps --filter | 조건으로 컨테이너 좁히기 | docker ps --filter "publish=8080" |
docker run | 에러 재현·포트 바꿔 재실행 | docker run -d -p 8081:80 --name app nginx:alpine |
docker build | 캐시 미적용 진단·강제 재빌드 | docker build --no-cache -t app . --progress=plain |
docker rm -f | 실습·문제 컨테이너 강제 정리 | docker rm -f port-occupier oom-test |
ss / lsof | 포트 충돌 시 호스트 점유 프로세스 확인 | ss -tlnp | grep 8080 / lsof -i :8080 |
관련 모듈로 더 깊이:
- 백엔드 서버 이슈를 쫓는 도커 셸 접속과 디버깅 기법 — exec·nsenter·디버그 사이드카로 한 단계 더 깊이 들어가는 진단 기법
- 포트 바인딩 오류 방지와 컨테이너의 핵심 상태 변화 — 컨테이너 즉시 종료·ExitCode의 근본인 생명주기 상태 변화
- 컨테이너 로그 유실 방지와 효율적인 실시간 로깅 전략 — 에러 진단의 첫 단계인
docker logs를 유실 없이 다루는 법
다음 모듈에서는 인터넷이 차단된 폐쇄망 환경에서 Docker 이미지를 안전하게 이전하는 Save & Load 기법을 다룹니다.