infra
Platform

모듈 맵

[Docker] 백엔드 서버 이슈를 쫓는 도커 셸 접속과 디버깅 기법

0 / 27 완료

펼치기
0 / 27 완료0%

Docker · 20 / 27

[Docker] 백엔드 서버 이슈를 쫓는 도커 셸 접속과 디버깅 기법

docker exec, nsenter, 에phemeral 디버그 컨테이너로 프로덕션 컨테이너 문제를 진단합니다

실행 중인 컨테이너 디버깅 기법

🚨INCIDENT ALERT
HIGH

새벽 장애에서 로그만으로 원인이 안 보이고, 컨테이너는 계속 재시작됩니다. 이때 무작정 재배포하면 증거가 사라지고 동일 장애가 반복됩니다. 필요한 것은 실행 중 상태를 보존한 채 내부/메타데이터/리소스를 순서대로 확인하는 디버깅 루틴입니다. 이 모듈은 exec부터 inspect, OOM 판별까지 운영 대응 흐름을 실전 기준으로 익히게 합니다.

배포하고 나서 문제가 생겼습니다. 로그만으로는 원인을 알 수 없고, 새벽 2시에 프로덕션 컨테이너가 크래시 루프에 빠졌습니다. 이 챕터에서는 실행 중인 컨테이너를 안전하게 들여다보고, 문제를 진단하고, 필요하면 시스템 수준까지 파고드는 모든 기법을 다룹니다.


이번 챕터에서 배울 것

컨테이너가 프로덕션에서 문제를 일으킬 때 사용하는 진단 도구들을 체계적으로 익힙니다. 셸이 없는 distroless 컨테이너부터 OOMKilled 메모리 장애까지 실전 시나리오를 중심으로 학습합니다.

  • 1docker exec -it로 실행 중인 컨테이너 내부 진입 — 다양한 활용 패턴
  • 2docker inspect --format으로 JSON 메타데이터에서 원하는 정보 추출
  • 3docker stats와 docker top으로 실시간 리소스와 프로세스 모니터링
  • 4nsenter로 네임스페이스 직접 진입 — distroless 컨테이너 디버깅
  • 5docker cp로 컨테이너 파일 추출 및 주입
  • 6임시 디버그 사이드카 컨테이너 패턴 — 프로덕션 컨테이너를 수정하지 않고 진단
실습 환경 준비

nginx와 python 컨테이너를 백그라운드로 실행한 상태에서 실습을 진행합니다. nsenter는 대부분의 Linux 배포판에 기본 포함되어 있습니다(util-linux 패키지).

Docker 설치 확인
docker --version
실습용 nginx 컨테이너 미리 실행
docker run -d --name debug-nginx -p 8080:80 nginx:alpine
실습용 Python 앱 컨테이너 실행
docker run -d --name debug-python python:3.11-slim sleep infinity
nsenter 설치 확인 (util-linux 패키지에 포함)
which nsenter || echo 'nsenter 미설치: sudo apt-get install util-linux'
실습 환경 정리 명령어 (실습 후 사용)

docker rm -f debug-nginx debug-python

💡개념

docker exec — 실행 중인 컨테이너 내부 접근의 모든 것

프로덕션 컨테이너에서 502 오류가 납니다. 로그에는 "upstream connect error"만 찍히고 원인이 안 보입니다. 컨테이너 안에 직접 들어가서 nginx 설정을 확인하거나, 실제로 포트가 열려 있는지 봐야 합니다. docker exec -it myapp sh로 컨테이너에 진입하면 마치 서버에 SSH로 접속한 것처럼 내부를 탐색할 수 있습니다. 재시작 없이, 이미지 수정 없이, 실행 중인 상태 그대로 들어갑니다. 이 ConceptBlock에서는 docker exec의 동작 원리와 디버깅에 쓰이는 주요 사용 패턴을 다룹니다.

docker exec 동작 원리

docker exec 기본 원리

docker exec는 이미 실행 중인 컨테이너에 새로운 프로세스를 추가로 실행합니다. docker run이 새 컨테이너를 생성하는 것과 다릅니다. exec으로 실행한 프로세스는 컨테이너의 네임스페이스(파일시스템, 네트워크, PID)를 공유합니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part5/exam_20 && cd /tmp/docker/part5/exam_20

# 기본 형식
docker exec [옵션] <컨테이너명 또는 ID> <명령어> [인수...]

# -i: stdin 연결 (interactive)
# -t: pseudo-TTY 할당 (터미널 에뮬레이션)
# -e: 환경 변수 설정
# -u: 실행할 사용자 지정
# -w: 작업 디렉토리 지정

주요 사용 패턴

셸 접속 (인터랙티브 디버깅)

Docker
# bash가 있는 컨테이너 (Ubuntu, Debian 기반)
docker exec -it debug-nginx bash

# sh만 있는 컨테이너 (Alpine 기반)
docker exec -it debug-nginx sh

# 컨테이너 내부에 진입하면:
# / # (Alpine) 또는 root@<id>:~# (Debian) 프롬프트 표시

# 나가기
exit  # 또는 Ctrl+D

단발성 명령 실행 (셸 없이)

Docker
# 컨테이너 내부에서 파일 확인
docker exec debug-nginx cat /etc/nginx/nginx.conf

# 환경 변수 확인
docker exec debug-nginx env

# 프로세스 목록 확인 (ps가 있는 경우)
docker exec debug-nginx ps aux

# 네트워크 상태 확인
docker exec debug-nginx netstat -tlnp 2>/dev/null || \
  docker exec debug-nginx ss -tlnp

# 파일시스템 사용량
docker exec debug-nginx df -h

# 특정 포트 리스닝 여부 확인
docker exec debug-nginx wget -qO- http://localhost:80 | head -20

특정 사용자로 명령 실행

Docker
# root 권한으로 실행 (앱이 비root 사용자로 실행 중이어도 진입 가능)
docker exec -u root -it myapp bash

# 특정 사용자 ID로 실행
docker exec -u 1000:1000 myapp id
# uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)

# 실행 중인 앱과 동일한 사용자로 실행 (권한 문제 재현 시)
APP_UID=$(docker exec myapp id -u)
docker exec -u $APP_UID myapp ls -la /app

특정 디렉토리에서 명령 실행

Docker
# 작업 디렉토리를 지정하여 상대 경로 명령 실행
docker exec -w /app myapp ls -la

# 앱 설정 파일 내용 확인
docker exec -w /etc/nginx debug-nginx cat nginx.conf

컨테이너 내부에서 디버그 도구 설치 후 사용

Docker
# 컨테이너 내부에서 네트워크 도구 임시 설치
docker exec -it debug-nginx sh -c "apk add --no-cache curl && curl -sv http://localhost"

# Debian 계열에서
docker exec -it debug-python bash -c "apt-get install -y -q strace && strace python app.py"

💡개념

docker inspect — JSON 메타데이터 마스터하기

컨테이너가 종료됐는데 원인이 안 보입니다. docker logs는 아무것도 없고, 마지막으로 뭔가 잘못됐다는 힌트도 없습니다. docker inspectOOMKilled: trueExitCode: 137을 확인하는 순간 원인이 명확해집니다. 메모리 부족으로 커널이 죽인 겁니다. inspect가 출력하는 JSON은 150줄이 넘지만, --format으로 필요한 필드만 뽑으면 스크립트에서도 바로 활용할 수 있습니다. 이 ConceptBlock에서는 docker inspect 출력 구조와 Go 템플릿으로 원하는 정보를 추출하는 방법을 다룹니다.

docker inspect 출력 구조

docker inspect 출력 구조

docker inspect는 컨테이너(또는 이미지, 네트워크, 볼륨)의 상세 정보를 JSON 배열로 출력합니다. 출력이 방대하므로 --format으로 필요한 필드만 추출합니다.

로컬 터미널
# 전체 JSON 출력 (약 150줄 이상)
docker inspect debug-nginx

# 출력 구조:
# [
#   {
#     "Id": "...",
#     "State": { "Status": "running", "Pid": 1234, ... },
#     "NetworkSettings": { "IPAddress": "172.17.0.2", ... },
#     "HostConfig": { "Memory": 0, "CpuShares": 0, ... },
#     "Mounts": [...],
#     "Config": { "Env": [...], "Cmd": [...], ... },
#     ...
#   }
# ]

--format으로 특정 필드 추출 (Go 템플릿)

로컬 터미널
# IP 주소 추출 (브릿지 네트워크)
docker inspect --format '{{.NetworkSettings.IPAddress}}' debug-nginx
# 172.17.0.2

# 컨테이너 상태 확인
docker inspect --format '{{.State.Status}}' debug-nginx
# running

# PID 추출 (nsenter에 활용)
docker inspect --format '{{.State.Pid}}' debug-nginx
# 12345

# 마운트된 볼륨 목록
docker inspect --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' debug-nginx
# /var/lib/docker/volumes/nginx-data/_data -> /var/cache/nginx

# 환경 변수 목록
docker inspect --format '{{range .Config.Env}}{{.}}{{"\n"}}{{end}}' debug-nginx
# PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# NGINX_VERSION=1.25.3
# ...

# 메모리 제한 확인 (0이면 제한 없음)
docker inspect --format '{{.HostConfig.Memory}}' debug-nginx
# 0

# OOMKilled 여부 확인 (장애 진단)
docker inspect --format '{{.State.OOMKilled}}' <crashed-container>
# true  ← 메모리 부족으로 종료됨

# 재시작 횟수 확인
docker inspect --format '{{.RestartCount}}' myapp
# 7  ← 7번 재시작됨 (문제 징후)

# 종료 코드 확인
docker inspect --format '{{.State.ExitCode}}' myapp
# 137  ← SIGKILL (OOMKilled)

유용한 복합 포맷 예시

로컬 터미널
# 여러 필드를 한 번에 출력
docker inspect --format \
  'ID: {{.Id}}
Status: {{.State.Status}}
Started: {{.State.StartedAt}}
IP: {{.NetworkSettings.IPAddress}}
OOMKilled: {{.State.OOMKilled}}
ExitCode: {{.State.ExitCode}}' myapp

# 사용자 정의 네트워크의 IP 추출 (브릿지 네트워크가 아닌 경우)
docker inspect --format \
  '{{range $net, $cfg := .NetworkSettings.Networks}}{{$net}}: {{$cfg.IPAddress}}{{"\n"}}{{end}}' \
  debug-nginx
# bridge: 172.17.0.2

# JSON 출력을 jq로 처리 (더 복잡한 쿼리)
docker inspect debug-nginx | jq '.[0].State'
docker inspect debug-nginx | jq '.[0].NetworkSettings.Networks'

여러 컨테이너 일괄 조회

로컬 터미널
# 모든 실행 중인 컨테이너의 이름과 IP 일괄 출력
docker ps -q | xargs docker inspect --format \
  '{{.Name}} — {{.NetworkSettings.IPAddress}}'
# /debug-nginx — 172.17.0.2
# /debug-python — 172.17.0.3

# 메모리 제한이 없는 컨테이너 찾기 (보안/안정성 감사)
docker ps -q | xargs docker inspect --format \
  '{{if eq .HostConfig.Memory 0}}{{.Name}}: 메모리 제한 없음{{end}}'

💡개념

docker stats와 docker top — 실시간 리소스 모니터링

배포 후 API 응답이 갑자기 느려졌는데 로그에는 에러가 없습니다. docker stats를 켜는 순간 특정 컨테이너의 CPU가 95%에서 내려오지 않는 것이 보입니다. 어떤 프로세스가 CPU를 잡아먹는지는 docker top으로 확인합니다. 로그만 봐서는 절대 알 수 없는 문제를 리소스 모니터링으로 5초 만에 찾는 경우가 현장에서 자주 있습니다. 이 ConceptBlock에서는 docker stats와 docker top으로 컨테이너 리소스와 프로세스를 실시간으로 파악하는 방법을 다룹니다.

docker stats와 top 모니터링

docker stats

로컬 터미널
# 모든 실행 중인 컨테이너의 리소스 사용량 실시간 모니터링 (htop처럼 갱신)
docker stats

# 출력 예시:
# CONTAINER ID   NAME           CPU %   MEM USAGE / LIMIT   MEM %   NET I/O         BLOCK I/O   PIDS
# a1b2c3d4e5f6   debug-nginx    0.00%   3.281MiB / 7.77GiB  0.04%   656B / 0B       0B / 0B     2
# f9e8d7c6b5a4   debug-python   0.00%   12.45MiB / 7.77GiB  0.16%   876B / 0B       0B / 0B     1

# 특정 컨테이너만 모니터링
docker stats debug-nginx debug-python

# 한 번만 스냅샷 출력 (스크립트에 활용)
docker stats --no-stream

# 커스텀 포맷 출력
docker stats --no-stream --format \
  "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
# NAME           CPU %   MEM USAGE / LIMIT   MEM %
# debug-nginx    0.00%   3.281MiB / 7.77GiB  0.04%

컬럼 설명

CPU %        — CPU 사용률 (호스트 전체 대비 %)
MEM USAGE    — 현재 메모리 사용량 / --memory 제한값
MEM %        — 메모리 사용률 (제한 대비)
NET I/O      — 네트워크 수신 / 송신 누적 바이트
BLOCK I/O    — 디스크 읽기 / 쓰기 누적 바이트
PIDS         — 컨테이너 내 프로세스/스레드 수

docker top — 컨테이너 내 프로세스 목록

로컬 터미널
# 컨테이너 내 프로세스 목록 (ps 형식)
docker top debug-nginx
# UID     PID    PPID   C   STIME   TTY   TIME       CMD
# root    12345  12300  0   09:00   ?     00:00:00   nginx: master process nginx -g daemon off;
# 101     12346  12345  0   09:00   ?     00:00:00   nginx: worker process
# 101     12347  12345  0   09:00   ?     00:00:00   nginx: worker process

# ps 옵션 전달 가능
docker top debug-nginx aux

# 특정 프로세스가 실행 중인지 확인
docker top myapp | grep "python\|node\|java"

# 좀비 프로세스 확인 (STAT에 Z가 있는 것)
docker top myapp aux | awk '$8 ~ /Z/ {print "좀비 프로세스:", $0}'

메모리 사용량 상세 분석

Docker
# /proc/meminfo로 컨테이너 내 메모리 세부 정보
docker exec myapp cat /proc/meminfo

# cgroup 메모리 통계 직접 확인 (호스트에서)
PID=$(docker inspect --format '{{.State.Pid}}' myapp)
cat /sys/fs/cgroup/memory/docker/$(docker inspect --format '{{.Id}}' myapp)/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/docker/$(docker inspect --format '{{.Id}}' myapp)/memory.stat

💡개념

nsenter — 셸 없는 컨테이너 디버깅

nsenter 네임스페이스 진입

nsenter가 필요한 상황

distroless 이미지나 scratch 기반 이미지는 운영 환경에서 권장됩니다. 공격 표면을 줄이고 이미지를 경량화하기 때문입니다. 하지만 문제가 생기면 docker exec으로 진입할 수 없습니다.

Docker
# distroless 컨테이너에 exec 시도
docker exec -it distroless-app sh
# OCI runtime exec failed: exec failed: unable to start container process:
# exec: "sh": executable file not found in $PATH: unknown

docker exec -it distroless-app bash
# OCI runtime exec failed: exec failed: unable to start container process:
# exec: "bash": executable file not found in $PATH: unknown

nsenter 동작 원리

Linux 네임스페이스 종류:
  mnt  — 파일시스템 마운트 네임스페이스
  pid  — 프로세스 ID 네임스페이스
  net  — 네트워크 인터페이스/라우팅 네임스페이스
  ipc  — IPC(공유 메모리, 세마포어) 네임스페이스
  uts  — 호스트명/도메인명 네임스페이스
  user — 사용자/그룹 ID 네임스페이스

nsenter --target <PID> --net --pid --mnt
       ↑ 대상 프로세스의 네임스페이스로 진입
       실행할 명령은 호스트의 파일시스템에서 가져옴 (컨테이너에 없어도 됨)

nsenter 기본 사용법

로컬 터미널
# 1단계: 컨테이너의 호스트 PID 확인
PID=$(docker inspect --format '{{.State.Pid}}' distroless-app)
echo "컨테이너 PID: $PID"
# 컨테이너 PID: 23456

# 2단계: 네트워크 네임스페이스 진입 (네트워크만)
sudo nsenter --target $PID --net -- ip addr
# 1: lo: <LOOPBACK,UP,LOWER_UP>
#     inet 127.0.0.1/8 scope host lo
# 2: eth0@if45: <BROADCAST,MULTICAST,UP,LOWER_UP>
#     inet 172.17.0.5/16 brd 172.17.255.255 scope global eth0

# 3단계: 모든 네임스페이스에 진입 (완전한 컨테이너 뷰)
sudo nsenter --target $PID --mount --uts --ipc --net --pid -- bash
# 이제 distroless 컨테이너의 파일시스템, 네트워크, 프로세스가 보임
# bash는 호스트의 bash를 사용하므로 컨테이너에 없어도 됨

# 4단계: 컨테이너 파일시스템 탐색
sudo nsenter --target $PID --mount -- ls /
sudo nsenter --target $PID --mount -- cat /app/config.json
sudo nsenter --target $PID --mount -- find /app -name "*.log"

네트워크 진단 (nsenter + 호스트 도구)

로컬 터미널
PID=$(docker inspect --format '{{.State.Pid}}' myapp)

# 컨테이너 네트워크 네임스페이스에서 ss 실행
sudo nsenter --target $PID --net -- ss -tlnp
# Netid  State   Recv-Q Send-Q  Local Address:Port  Peer Address:Port
# tcp    LISTEN  0      128     0.0.0.0:8080        0.0.0.0:*

# 컨테이너의 라우팅 테이블 확인
sudo nsenter --target $PID --net -- ip route
# default via 172.17.0.1 dev eth0
# 172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.5

# 컨테이너 내 DNS 설정
sudo nsenter --target $PID --mount -- cat /etc/resolv.conf

# 외부 연결 확인 (호스트의 curl 사용)
sudo nsenter --target $PID --net -- curl -sv https://api.example.com/health

strace로 시스템 콜 추적

로컬 터미널
PID=$(docker inspect --format '{{.State.Pid}}' myapp)

# 컨테이너 메인 프로세스의 시스템 콜 추적
sudo strace -p $PID -f -e trace=network,file 2>&1 | head -50

# 출력 예시:
# [pid 23456] openat(AT_FDCWD, "/app/config.json", O_RDONLY) = 3
# [pid 23456] read(3, '{"database": "postgres://..."}', 4096) = 98
# [pid 23456] socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 4
# [pid 23456] connect(4, {sa_family=AF_INET, sin_port=htons(5432), ...}) = -1 ECONNREFUSED

# ECONNREFUSED: 데이터베이스 연결 실패 → 연결 설정 문제임을 확인

# 파일 접근만 추적 (권한 문제 진단)
sudo strace -p $PID -f -e trace=open,openat,read,write 2>&1 | grep -i "denied\|EPERM\|EACCES"

💡개념

docker cp와 디버그 사이드카 컨테이너 패턴

Java 서비스가 OOMKilled로 종료됐습니다. 힙 덤프가 /tmp/heap.hprof에 생성됐는데 컨테이너가 이미 Exited 상태입니다. docker cp는 중지된 컨테이너에서도 파일을 꺼낼 수 있습니다. 반대로 distroless 컨테이너에서 exec이 안 될 때 같은 네트워크를 공유하는 디버그 컨테이너(사이드카)를 붙이면 curl, tcpdump 같은 도구를 컨테이너 수정 없이 사용할 수 있습니다. 이 ConceptBlock에서는 docker cp로 파일을 추출·주입하는 방법과 디버그 사이드카 컨테이너 패턴을 다룹니다.

docker cp와 사이드카 패턴

docker cp — 파일 추출과 주입

로컬 터미널
# 컨테이너 → 호스트로 파일 복사 (실행 중이거나 중지된 컨테이너 모두 가능)
docker cp debug-nginx:/etc/nginx/nginx.conf ./nginx.conf.bak
docker cp debug-nginx:/var/log/nginx/error.log ./error.log

# 호스트 → 컨테이너로 파일 복사 (임시 설정 변경 시)
docker cp ./nginx-debug.conf debug-nginx:/etc/nginx/conf.d/debug.conf

# 디렉토리 전체 복사
docker cp debug-nginx:/etc/nginx ./nginx-config-backup/

# 크래시된 컨테이너(Exited 상태)에서도 파일 추출 가능
docker ps -a | grep myapp
# abc123  myapp  "..."  5 min ago  Exited (137)
docker cp abc123:/app/heap-dump.hprof ./heap-dump.hprof

임시 구성 변경으로 디버깅

로컬 터미널
# 1. 현재 nginx 설정 파일 추출
docker cp debug-nginx:/etc/nginx/nginx.conf ./nginx.conf

# 2. 로컬에서 디버그 설정 수정 (로그 레벨 변경 등)
sed -i 's/error_log .*/error_log \/var\/log\/nginx\/debug.log debug;/' ./nginx.conf

# 3. 수정된 설정 파일을 컨테이너에 다시 주입
docker cp ./nginx.conf debug-nginx:/etc/nginx/nginx.conf

# 4. nginx 설정 리로드 (컨테이너 재시작 없이)
docker exec debug-nginx nginx -s reload

디버그 사이드카 컨테이너 패턴

프로덕션 컨테이너를 전혀 수정하지 않고 같은 네트워크와 볼륨을 공유하는 디버그 전용 컨테이너를 임시로 실행하는 패턴입니다.

Docker
# 문제가 있는 프로덕션 컨테이너 (distroless, 수정 불가)
docker run -d --name prod-app \
  --network my-network \
  --volume app-data:/data \
  myapp:distroless

# 디버그 사이드카: 같은 네트워크와 볼륨 공유, 풍부한 도구 포함
docker run -it --rm \
  --network container:prod-app \
  --volumes-from prod-app \
  --pid container:prod-app \
  ubuntu:22.04 bash

# 사이드카 컨테이너 내부에서:
# - prod-app과 같은 네트워크 인터페이스 공유 (localhost로 앱에 접근)
# - prod-app의 볼륨 데이터에 접근
# - prod-app의 프로세스를 pid 네임스페이스에서 확인

# 사이드카에서 네트워크 진단
apt-get install -y curl netcat-openbsd nmap
curl -v http://localhost:8080/health
nc -zv localhost 5432

# 사이드카에서 공유 볼륨 파일 확인
ls -la /data
cat /data/app.log

네트워크 공유 사이드카로 트래픽 캡처

Docker
# tcpdump가 있는 사이드카로 컨테이너 네트워크 트래픽 캡처
docker run -it --rm \
  --network container:debug-nginx \
  --cap-add NET_ADMIN \
  nicolaka/netshoot tcpdump -i eth0 -w /tmp/capture.pcap port 80

# 캡처 파일을 호스트로 복사해서 Wireshark로 분석
NETSHOOT_ID=$(docker ps -q -f name=netshoot)
docker cp $NETSHOOT_ID:/tmp/capture.pcap ./nginx-traffic.pcap

# nicolaka/netshoot: 네트워크 디버깅 도구가 모두 포함된 전용 이미지
# (tcpdump, curl, dig, nmap, iperf3, netstat, ss 등)

기본 실습

실행 중인 nginx 컨테이너 내부 구조 탐색

목표

실행 중인 nginx 컨테이너에 진입하여 설정 파일 구조, 실행 중인 프로세스, 네트워크 상태를 직접 확인합니다.

실습

1단계: nginx 컨테이너 준비

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part4/exam_2 && cd /tmp/docker/part4/exam_2

# 디버깅 대상 컨테이너용 Dockerfile 생성
cat > Dockerfile << 'EOF'
FROM nginx:alpine
RUN echo "debug test" > /usr/share/nginx/html/debug.html
EOF

이제 실습을 진행합니다.

Docker
# 이미 실행 중이 아니라면 실행
docker run -d --name debug-nginx -p 8080:80 nginx:alpine
docker ps -f name=debug-nginx

# CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS
# a1b2c3d4e5f6   nginx:alpine   "/docker-entrypoint.…"   5 seconds ago   Up 4 seconds   0.0.0.0:8080->80/tcp

2단계: 컨테이너 내부 sh 접속 (Alpine이므로 sh 사용)

Docker
docker exec -it debug-nginx sh

# 이제 컨테이너 내부
# / #

3단계: 내부에서 탐색

로컬 터미널
# 컨테이너 OS 확인
cat /etc/os-release
# NAME="Alpine Linux"
# VERSION_ID=3.18.4

# nginx 버전 및 컴파일 옵션 확인
nginx -V 2>&1 | head -5
# nginx version: nginx/1.25.3
# built with OpenSSL 3.1.3

# nginx 설정 파일 구조 확인
find /etc/nginx -type f | sort
# /etc/nginx/conf.d/default.conf
# /etc/nginx/mime.types
# /etc/nginx/nginx.conf

# 메인 설정 파일 확인
cat /etc/nginx/nginx.conf

# 프로세스 확인
ps aux
# PID   USER     TIME  COMMAND
#   1   root      0:00 nginx: master process nginx -g daemon off;
#  30   nginx     0:00 nginx: worker process

# 네트워크 소켓 확인
netstat -tlnp 2>/dev/null || ss -tlnp
# tcp   LISTEN   0   511   0.0.0.0:80   0.0.0.0:*   users:(("nginx",pid=30,...))

# 환경 변수 확인
env | sort

exit

4단계: 컨테이너 외부에서 단발성 명령

Docker
# nginx 설정 문법 검증
docker exec debug-nginx nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# 현재 연결 수 확인
docker exec debug-nginx sh -c "ss -s"
# Total: 6
# TCP:   3 (estab 0, closed 0, orphaned 0, timewait 0)

# 설정 파일 추출
docker cp debug-nginx:/etc/nginx/conf.d/default.conf ./default.conf.bak
cat ./default.conf.bak
🔍실행 후 확인할 것
  • `docker exec`, `docker inspect --format`, `docker stats`를 용도별로 구분해 사용했는가?
  • OOMKilled/ExitCode를 통해 "증상"이 아닌 종료 원인을 확인했는가?
  • 원인 확인 전 컨테이너를 삭제하지 않고 증거를 먼저 수집했는가?

docker inspect --format으로 IP 주소와 메타데이터 추출

목표

--format 플래그로 다양한 컨테이너 메타데이터를 스크립트에서 활용할 수 있는 형태로 추출합니다.

실습

1단계: 기본 정보 추출

로컬 터미널
# IP 주소 추출
docker inspect --format '{{.NetworkSettings.IPAddress}}' debug-nginx
# 172.17.0.2

# 컨테이너 상태 확인
docker inspect --format 'Status: {{.State.Status}} | Running: {{.State.Running}}' debug-nginx
# Status: running | Running: true

# 시작 시각 확인
docker inspect --format '{{.State.StartedAt}}' debug-nginx
# 2026-03-28T02:15:43.123456789Z

2단계: 환경 변수 전체 목록 출력

로컬 터미널
docker inspect --format '{{range .Config.Env}}{{.}}
{{end}}' debug-nginx
# PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# NGINX_VERSION=1.25.3
# PKG_RELEASE=1
# NJS_VERSION=0.8.2

3단계: 네트워크 정보 상세 추출

로컬 터미널
# 사용자 정의 네트워크까지 포함한 전체 IP 정보
docker inspect --format \
  '{{range $net, $cfg := .NetworkSettings.Networks}}네트워크: {{$net}} | IP: {{$cfg.IPAddress}} | Gateway: {{$cfg.Gateway}}
{{end}}' debug-nginx
# 네트워크: bridge | IP: 172.17.0.2 | Gateway: 172.17.0.1

# 포트 바인딩 정보
docker inspect --format '{{range $port, $bindings := .NetworkSettings.Ports}}{{$port}} -> {{range $bindings}}{{.HostPort}}{{end}}
{{end}}' debug-nginx
# 80/tcp -> 8080

4단계: 장애 진단용 스크립트

로컬 터미널
# 컨테이너 장애 진단 스크립트
cat > /tmp/diagnose.sh << 'SCRIPT'
#!/bin/bash
CONTAINER=${1:-debug-nginx}

echo "=== 컨테이너 장애 진단: $CONTAINER ==="
echo ""

echo "[기본 상태]"
docker inspect --format \
  'Status: {{.State.Status}}
ExitCode: {{.State.ExitCode}}
OOMKilled: {{.State.OOMKilled}}
RestartCount: {{.RestartCount}}' $CONTAINER

echo ""
echo "[리소스 정보]"
docker inspect --format \
  'Memory Limit: {{.HostConfig.Memory}} bytes
CPU Shares: {{.HostConfig.CpuShares}}' $CONTAINER

echo ""
echo "[네트워크]"
docker inspect --format \
  'IP: {{.NetworkSettings.IPAddress}}' $CONTAINER

echo ""
echo "[최근 로그 (마지막 20줄)]"
docker logs --tail 20 $CONTAINER 2>&1
SCRIPT

chmod +x /tmp/diagnose.sh
bash /tmp/diagnose.sh debug-nginx

크래시 루프 컨테이너 로그 수집과 OOMKilled 진단

목표

메모리 제한에 걸려 OOMKilled로 종료되는 컨테이너를 시뮬레이션하고, 사후 진단 방법을 익힙니다.

실습

1단계: 의도적으로 OOMKilled 되는 컨테이너 생성

Docker
# 메모리를 20MB로 제한하고, 100MB를 사용하는 파이썬 스크립트 실행
docker run -d --name oom-test \
  --memory=20m \
  python:3.11-slim \
  python -c "
import time
data = []
for i in range(100):
    data.append('x' * 1024 * 1024)  # 매 반복 1MB 할당
    print(f'Allocated {i+1}MB', flush=True)
    time.sleep(0.1)
"

# 잠시 대기
sleep 5

# 상태 확인 — OOMKilled로 종료되었을 것
docker ps -a -f name=oom-test
# CONTAINER ID   IMAGE          COMMAND     CREATED    STATUS                        NAMES
# b2c3d4e5f6a7   python:3.11-slim "python -c..." 10s ago  Exited (137) 3 seconds ago   oom-test

2단계: OOMKilled 원인 확인

로컬 터미널
# 종료 코드 확인 (137 = SIGKILL)
docker inspect --format '{{.State.ExitCode}}' oom-test
# 137

# OOMKilled 플래그 확인
docker inspect --format '{{.State.OOMKilled}}' oom-test
# true

# 종료 직전 로그 확인
docker logs oom-test
# Allocated 1MB
# Allocated 2MB
# ...
# Allocated 18MB
# (이후 로그 없음 — 갑자기 종료됨)

3단계: 종합 진단 정보 수집

로컬 터미널
# 전체 장애 정보 수집
echo "=== OOM 장애 진단 ==="
docker inspect oom-test | python3 -c "
import json, sys
data = json.load(sys.stdin)[0]
state = data['State']
hc = data['HostConfig']
print(f'상태: {state[\"Status\"]}')
print(f'종료코드: {state[\"ExitCode\"]}')
print(f'OOMKilled: {state[\"OOMKilled\"]}')
print(f'시작시각: {state[\"StartedAt\"]}')
print(f'종료시각: {state[\"FinishedAt\"]}')
print(f'메모리제한: {hc[\"Memory\"]} bytes ({hc[\"Memory\"] // 1024 // 1024}MB)')
print(f'재시작횟수: {data[\"RestartCount\"]}')
"

# 호스트 커널 로그에서 OOM 이벤트 확인
sudo dmesg | grep -i "oom\|killed" | tail -5
# [12345.678] oom_reaper: reaped process 99999 (python), now anon-rss:0kB, file-rss:0kB

4단계: 정리

로컬 터미널
docker rm oom-test

트러블슈팅

문제 상황

로컬 터미널
$ docker exec -it my-distroless-app bash
OCI runtime exec failed: exec failed: unable to start container process:
exec: "bash": executable file not found in $PATH: unknown

$ docker exec -it my-distroless-app sh
OCI runtime exec failed: exec failed: unable to start container process:
exec: "sh": executable file not found in $PATH: unknown

원인

gcr.io/distroless/*, scratch, 또는 매우 최소화된 이미지들은 셸을 포함하지 않습니다. docker exec은 컨테이너 내부에 실행 파일이 있어야 동작합니다.

해결 방법 1: nsenter (즉시 적용 가능, 권장)

로컬 터미널
# 컨테이너 PID 확인
PID=$(docker inspect --format '{{.State.Pid}}' my-distroless-app)
echo "컨테이너 메인 프로세스 PID: $PID"

# 모든 네임스페이스에 진입 (호스트의 bash 사용)
sudo nsenter --target $PID --mount --uts --ipc --net --pid -- bash

# 이제 distroless 컨테이너의 파일시스템에서 탐색 가능
ls /app
cat /app/config.json
ps aux  # PID 네임스페이스의 프로세스 목록

해결 방법 2: 디버그 사이드카 컨테이너

Docker
# 같은 네트워크 네임스페이스를 공유하는 사이드카 실행
docker run -it --rm \
  --network container:my-distroless-app \
  --pid container:my-distroless-app \
  --volumes-from my-distroless-app \
  busybox sh

# 사이드카 내부에서:
# - 컨테이너와 같은 IP로 localhost 접근 가능
# - 컨테이너의 볼륨 파일 접근 가능
# - 컨테이너의 프로세스 목록 확인 가능

해결 방법 3: 디버그용 이미지 태그 별도 관리 (사전 예방)

Dockerfile
# Dockerfile에 디버그 스테이지 추가
# syntax=docker/dockerfile:1.6

FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o server .

# 프로덕션 이미지 (distroless)
FROM gcr.io/distroless/base-debian12 AS production
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

# 디버그 이미지 (셸 포함)
FROM debian:bookworm-slim AS debug
COPY --from=builder /app/server /server
RUN apt-get update && apt-get install -y \
    bash curl strace procps net-tools \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/server"]
Docker
# 프로덕션: distroless 이미지 사용
docker build --target production -t myapp:latest .

# 디버그: 셸 포함 이미지 사용
docker build --target debug -t myapp:debug .
docker run -it --rm myapp:debug bash

오류 메시지 비교

# exec 시 보이는 메시지들과 의미:
"executable file not found in $PATH"  → 해당 실행 파일이 컨테이너에 없음
"permission denied"                    → 실행 파일은 있으나 권한 없음
"no such file or directory"            → 바이너리 또는 의존 라이브러리 누락

문제 상황

로컬 터미널
# 실행 중인 컨테이너에서 파일 쓰기 시도
$ docker exec debug-nginx sh -c "echo 'test' > /etc/nginx/test.txt"
sh: /etc/nginx/test.txt: Read-only file system

# 또는 일반 권한 문제
$ docker exec myapp touch /app/debug.flag
touch: cannot touch '/app/debug.flag': Permission denied

원인 분류

원인 1: --read-only 플래그로 읽기 전용 컨테이너 실행

로컬 터미널
# 현재 컨테이너가 read-only인지 확인
docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' myapp
# true ← read-only 루트 파일시스템

# 또는 docker ps 출력에서 확인
docker inspect myapp | grep -i readonly

원인 2: 비root 사용자로 실행 중

Docker
# 현재 실행 사용자 확인
docker exec myapp id
# uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)

# 파일 소유자 확인
docker exec myapp ls -la /app
# drwxr-xr-x  2 root    root    4096 ...  config/   ← root 소유, appuser 쓰기 불가
# drwxrwxr-x  2 appuser appuser 4096 ...  logs/     ← appuser 소유, 쓰기 가능

해결 방법

읽기 전용 컨테이너에서 임시 쓰기 영역 활용

Docker
# read-only 컨테이너는 특정 디렉토리만 tmpfs로 마운트하여 쓰기 허용
docker run -d \
  --read-only \
  --tmpfs /tmp:rw,size=100m \
  --tmpfs /var/log/nginx:rw \
  --name nginx-readonly \
  nginx:alpine

# 디버그 파일은 /tmp에 작성
docker exec nginx-readonly sh -c "nginx -T > /tmp/nginx-debug.conf 2>&1"
docker cp nginx-readonly:/tmp/nginx-debug.conf ./

root 사용자로 강제 진입 (보안 주의)

Docker
# -u root 옵션으로 root 사용자로 exec
docker exec -u root -it myapp bash

# root로 진입 후 소유권 임시 변경
chown appuser:appuser /app/config
chmod 755 /app/logs

파일 대신 stdout/stderr 활용 (디버그 로그)

Docker
# 파일에 쓰지 않고 표준 출력으로 디버그 정보 수집
docker exec myapp sh -c "cat /proc/net/tcp"     # 네트워크 연결
docker exec myapp sh -c "cat /proc/meminfo"     # 메모리 상태
docker exec myapp sh -c "ls -laR /app" > /tmp/app-files.txt  # 로컬에 저장

docker cp를 활용한 우회

로컬 터미널
# 읽기 전용이라도 docker cp는 컨테이너 밖에서 파일을 변경
# 수정이 필요한 파일을 호스트로 가져와 수정 후 다시 넣기
docker cp nginx-readonly:/etc/nginx/conf.d/default.conf ./
# 로컬에서 수정
nano ./default.conf
# 다시 컨테이너에 주입
docker cp ./default.conf nginx-readonly:/etc/nginx/conf.d/default.conf
docker exec nginx-readonly nginx -s reload

실무 맥락

💼
실무 맥락
현업 패턴

배경

스타트업 B사의 주문 처리 서비스. 새벽 2시, 모니터링 알림이 연속으로 발생합니다. 주문 서비스 컨테이너가 5분마다 재시작을 반복하며 --restart=always 정책으로 겨우 유지되고 있습니다. 온콜 엔지니어가 접속했을 때의 상황입니다.

사고 대응 순서

1단계: 현황 파악 (5분)

로컬 터미널
# 전체 컨테이너 상태 확인
docker ps -a
# CONTAINER ID   IMAGE          COMMAND   CREATED    STATUS                        NAMES
# 9f8e7d6c5b4a   order-service  "java …"  2 min ago  Up 1 minute (unhealthy)       order-service
# 3a2b1c0d9e8f   order-service  "java …"  7 min ago  Exited (137) 2 minutes ago    order-service_old

# 재시작 횟수와 OOMKilled 여부 확인
docker inspect order-service | python3 -c "
import json, sys
d = json.load(sys.stdin)[0]
print('재시작 횟수:', d['RestartCount'])
print('OOMKilled:', d['State']['OOMKilled'])
print('현재 메모리 사용:', end=' ')
"
# 재시작 횟수: 12
# OOMKilled: false  ← 현재 실행 중인 것은 아직 OOM 안 됨

# 방금 종료된 컨테이너에서 OOMKilled 확인
PREV_ID=$(docker ps -aq --filter "name=order-service_old" | head -1)
docker inspect $PREV_ID --format '{{.State.OOMKilled}} | ExitCode: {{.State.ExitCode}}'
# true | ExitCode: 137  ← 메모리 부족으로 종료됨

2단계: 메모리 사용 추이 모니터링 (10분)

로컬 터미널
# 실시간 메모리 모니터링
watch -n 2 'docker stats --no-stream order-service'
# CONTAINER      CPU %   MEM USAGE / LIMIT   MEM %   NET I/O   BLOCK I/O
# order-service  85.2%   892MiB / 1GiB       89.2%   ...       ...
# (2초 후)
# order-service  92.1%   951MiB / 1GiB       95.1%   ...       ...  ← 계속 증가 중!

3단계: 컨테이너 내부 상세 진단

Docker
# JVM 힙 메모리 상태 확인 (Java 서비스)
docker exec order-service sh -c "
  PID=\$(pgrep java)
  jstat -gcutil \$PID 5000 3
"
# S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT
# 0.00  88.23  98.14  97.43  96.5   93.2   1234   12.345   89   89.234
# ← Old Gen(O) 97%, Full GC 89회 → 메모리 누수 징후

# 스레드 덤프 (데드락, 스레드 누수 확인)
docker exec order-service sh -c "kill -3 \$(pgrep java)" 2>&1 | head -50

# 힙 덤프 생성 (메모리 누수 원인 분석용)
docker exec order-service sh -c "
  PID=\$(pgrep java)
  jmap -dump:format=b,file=/tmp/heap.hprof \$PID
"

# 힙 덤프 파일 호스트로 추출
docker cp order-service:/tmp/heap.hprof ./heap-$(date +%Y%m%d-%H%M).hprof
echo "힙 덤프 추출 완료: heap-$(date +%Y%m%d-%H%M).hprof"

4단계: 임시 조치 (메모리 제한 완화)

로컬 터미널
# 임시: 메모리 제한을 2GB로 높여 서비스 안정화
docker update --memory 2g --memory-swap 3g order-service
echo "메모리 제한 임시 증가: 1GB → 2GB"

# 메모리 모니터링 재확인
sleep 30
docker stats --no-stream order-service
# order-service  45.2%   934MiB / 2GiB   46.7%  ← 안정됨

5단계: 근본 원인 분석 (사후)

로컬 터미널
# 힙 덤프 분석 (Eclipse MAT 또는 jhat 사용)
jhat -port 7000 ./heap-20260328-0215.hprof &
# 브라우저에서 http://localhost:7000 접속

# 로그에서 메모리 관련 패턴 검색
docker logs order-service 2>&1 | grep -iE "outofmemory|leak|connection pool|queue size"
# 2026-03-28 02:10:23 WARN  HikariPool - Connection pool exhausted, queueSize=500
# ← DB 커넥션 풀 고갈 → 스레드 대기 → 메모리 증가 → OOM

# 결론: DB 커넥션 풀 크기가 부족하여 대기 스레드가 누적된 메모리 누수
# 해결: HikariCP maximumPoolSize 10 → 30으로 증가, 커넥션 타임아웃 설정 추가

정리 — 온콜 대응 체크리스트

새벽 장애 발생 시 디버깅 순서:
1. docker ps -a         — 전체 컨테이너 상태 파악
2. docker inspect       — OOMKilled, ExitCode, RestartCount 확인
3. docker logs --tail   — 종료 직전 로그 확인
4. docker stats         — 실시간 리소스 추이 모니터링
5. docker exec (가능 시) — 내부 진단 도구 실행
6. nsenter (distroless) — 셸 없는 컨테이너 진입
7. docker cp            — 힙 덤프, 로그 파일 추출
8. 임시 조치 (docker update 등)
9. 근본 원인 분석 및 코드/설정 수정

핵심 요약

개념명령/설정설명
셸 진입docker exec -it <id> bash 또는 sh실행 중인 컨테이너에 인터랙티브 세션
단발성 명령docker exec <id> cat /etc/config셸 없이 특정 명령만 실행
메타데이터 추출docker inspect --format '{{.NetworkSettings.IPAddress}}'Go 템플릿으로 원하는 필드만 추출
OOMKilled 확인docker inspect --format '{{.State.OOMKilled}}'메모리 부족 종료 여부 확인
실시간 리소스docker stats --no-streamCPU/메모리/네트워크 스냅샷
프로세스 목록docker top <id>컨테이너 내 실행 중인 프로세스
파일 추출docker cp <id>:/path/file ./local실행 중/중지된 컨테이너 모두 가능
distroless 진단sudo nsenter --target $PID --net --pid --mount -- bash셸 없는 컨테이너 네임스페이스 직접 진입
시스템 콜 추적sudo strace -p $PID -f프로세스의 시스템 콜 실시간 추적
사이드카 디버그docker run --network container:<id> nicolaka/netshoot프로덕션 컨테이너 수정 없이 네트워크 진단
트래픽 캡처docker run --network container:<id> tcpdump컨테이너 네트워크 패킷 분석

지식 확인

퀴즈 — 5문제

Q1

distroless 이미지로 만든 컨테이너에서 `docker exec -it <id> bash` 가 실패하는 이유는?

Q2

docker inspect --format '{{.NetworkSettings.IPAddress}}' <id> 명령의 목적은?

Q3

`docker stats` 명령어로 확인할 수 없는 정보는?

Q4

OOMKilled ExitCode 137 이 의미하는 것은?

Q5

nsenter를 사용하여 컨테이너 네임스페이스에 진입하는 가장 적절한 상황은?

0 / 5 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

docker-compose.yml로 nginx + 앱 컨테이너를 함께 정의하고, 서비스 간 통신과 볼륨 마운트를 구성한다.

35📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

docker중급 · 50
[Docker] 사내 프라이빗 레지스트리 구축과 안전한 이미지 관리 방법
Docker 트랙 계속
networking입문 · 45
[Network] OSI 7계층과 TCP/IP 4계층 모델 실무적 관점 분석
Networking 트랙 시작점