infra
Platform

모듈 맵

[Docker] pull, build, tag, push 이미지 관리 라이프사이클

0 / 27 완료

펼치기
0 / 27 완료0%

도커 & 컨테이너 · 03 / 27

[Docker] pull, build, tag, push 이미지 관리 라이프사이클

Docker 이미지의 레이어 구조를 이해하고 pull, build, tag, push 워크플로우를 익힙니다

🚨INCIDENT ALERT
HIGH

새 기능을 급하게 배포했는데 스테이징 서버는 myapp:latest, 운영 서버는 myapp:v1.2.3을 실행하고 있었습니다. 둘 다 같은 앱이라고 생각했지만 실제 이미지 ID가 달라서 한쪽에서만 버그가 재현됐습니다.

이미지는 단순한 압축 파일이 아니라 레이어, 태그, 레지스트리 참조가 얽힌 배포 단위입니다. 어떤 이미지를 pull했고, 어떤 태그로 build했으며, 어디에 push했는지 추적하지 못하면 롤백도 재현도 어려워집니다.

이미지 다루기 — pull, build, tag, push

이미지는 Docker의 핵심입니다. "한 번 빌드하고, 어디서든 실행한다(Build once, run anywhere)"는 철학은 이미지가 있기에 가능합니다. 이 모듈에서는 이미지의 레이어 구조를 이해하고, pull로 내려받고, build로 만들고, tag로 이름을 붙이고, push로 배포하는 전체 워크플로우를 익힙니다.

이번 챕터에서 배울 것
  • 1이미지를 레이어로 쌓는 이유와 레이어 구조를 설명할 수 있다
  • 2docker pull로 레지스트리에서 이미지를 가져올 수 있다
  • 3docker build로 Dockerfile에서 이미지를 만들 수 있다
  • 4docker tag로 이미지에 버전과 이름을 붙일 수 있다
  • 5docker push로 레지스트리에 이미지를 올릴 수 있다
  • 6inspect, history, prune으로 이미지를 관리할 수 있다
실습 환경 준비
로컬 이미지 목록 확인
docker images
Docker Hub 연결 상태 확인
docker pull hello-world
실습 디렉토리 생성
mkdir -p /tmp/docker-image-lab && cd /tmp/docker-image-lab
💡개념

이미지 레이어 구조 — Union File System

docker pull로 이미지를 내려받을 때 'Already exists'가 표시되면서 일부 레이어만 다운로드되는 걸 본 적이 있을 겁니다. 이미지 크기가 수백 MB인데 다운로드가 몇 초 만에 끝나는 경우도 있습니다. 이게 가능한 이유가 레이어 구조입니다. 레이어를 이해하면 왜 빌드 최적화에서 Dockerfile 명령 순서가 중요한지, 왜 COPY requirements.txt를 소스코드보다 먼저 하는지 이해할 수 있습니다. 이 ConceptBlock에서는 Docker 이미지의 레이어 구조와 Union File System이 어떻게 작동하는지 다룹니다.

이미지 레이어 구조와 레이어 공유 — 이미지는 Dockerfile 명령마다 쌓인 읽기 전용 레이어의 스택이고, 여러 이미지가 같은 베이스 레이어를 공유. docker pull에서 'Already exists'는 이미 가진 레이어를 건너뛰는 것. 자주 바뀌는 소스보다 의존성(requirements.txt)을 먼저 COPY해야 캐시가 유지됨확대

왜 레이어인가?

Docker 이미지는 여러 개의 읽기 전용 레이어를 쌓아 만든 구조입니다. 각 레이어는 이전 레이어에서 변경된 파일만 포함합니다. 이 방식을 Union File System(UnionFS)이라고 합니다.

레이어 구조의 이점:

레이어 (위→아래)nginx:alpine 이미지myapp:v1.0 이미지
상위nginx 설정 레이어앱 코드 레이어 (다름)
중간nginx 바이너리의존성 레이어 (다름)
베이스Alpine LinuxAlpine Linux (← 공유)

베이스 레이어는 같은 레이어 ID를 가지므로 디스크에 한 번만 저장됩니다.

두 이미지가 Alpine Linux 레이어를 공유합니다. 이미지 10개가 모두 Alpine Linux를 기반으로 해도, Alpine 레이어는 디스크에 한 번만 저장됩니다. docker pull 시 이미 있는 레이어는 다운로드하지 않아 속도도 빠릅니다.

레이어 확인

이미지의 레이어 구성을 직접 확인하면 각 Dockerfile 명령이 얼마나 큰 레이어를 만드는지, 어떤 레이어가 다른 이미지와 공유되는지 볼 수 있습니다. 빌드 최적화의 출발점이 됩니다.

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

# 이미지 레이어 히스토리 보기
docker history nginx:alpine

# 출력 예시:
# IMAGE          CREATED        CREATED BY                    SIZE
# a3b4c5d6e7f8   2 days ago     CMD ["nginx" "-g" "daemon…"   0B
# <missing>      2 days ago     EXPOSE map[80/tcp:{}]          0B
# <missing>      2 days ago     COPY 10-listen-on-ipv6-by…    0B
# <missing>      2 days ago     ENV NGINX_VERSION=1.25.3      0B
# <missing>      2 days ago     RUN /bin/sh -c set -x...      12.6MB
# <missing>      3 weeks ago    /bin/sh -c #(nop) ADD fil…   7.34MB  ← Alpine 기반

# 이미지 상세 정보 (레이어 SHA256 등)
docker inspect nginx:alpine | grep -A5 "Layers"
🔍실행 후 확인할 것
  • 레이어 순서 먼저: docker history nginx:alpine 출력에서 가장 아래(오래된) 레이어가 Alpine 기반 레이어인지 확인 — 레이어가 위에서 아래로 최신→기반 순서로 나열됨
  • SIZE 기준 판단: 레이어 크기가 100MB 이하면 경량, 500MB 이상 단일 레이어는 RUN 명령 최적화 필요, 1GB 이상 이미지는 멀티 스테이지 빌드 재검토 시점
  • <none> 태그와 CREATED 조합 해석: docker images에서 <none>:<none>은 dangling 이미지 — CREATED가 최근이면 반복 빌드로 생긴 것. docker image prune으로 정리 가능하며 방치 시 수GB 디스크 잠식
💡개념

docker pull — 레지스트리에서 이미지 내려받기

이미지 이름에 콜론이 들어간 형태(nginx:alpine, python:3.11-slim)를 보면서 어느 부분이 이름이고 어느 부분이 버전인지 헷갈릴 수 있습니다. 더 복잡하게는 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1.0처럼 URL 형태의 이미지 이름이 등장하기도 합니다. 이 이름 체계를 이해해야 레지스트리에서 원하는 이미지를 정확하게 가져오고, 직접 만든 이미지를 레지스트리에 올릴 때 올바른 형식으로 태그할 수 있습니다. 이 ConceptBlock에서는 이미지 이름 규칙과 태그별 특성, pull 명령의 실전 사용법을 다룹니다.

이미지 이름 구조 — 레지스트리/계정명/이름:태그확대

이미지 이름 규칙

Docker 이미지 이름은 레지스트리 주소, 계정명, 이미지명, 태그 네 부분으로 구성됩니다. 생략된 부분은 기본값으로 채워집니다.

[레지스트리주소/][계정명/]이미지명[:태그]

예시:
  nginx                          → Docker Hub, nginx 공식 이미지, :latest
  nginx:1.25-alpine              → nginx 버전 1.25, Alpine 변형
  ubuntu:22.04                   → ubuntu 22.04 LTS
  python:3.11-slim               → Python 3.11, slim 변형 (불필요한 패키지 제거)
  myusername/myapp:v1.2.3        → Docker Hub, 개인 저장소
  ghcr.io/myorg/myapp:latest     → GitHub Container Registry
  123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1  → AWS ECR

태그 선택 가이드:

태그마다 크기, 보안, 호환성 특성이 다릅니다. 프로덕션에서는 가변 태그(:latest)보다 버전 고정 태그를 씁니다.

태그특성용도
:latest최신 버전 (가변)개발 환경에서만
:1.25.3정확한 버전 고정프로덕션 권장
:alpineAlpine Linux 기반, 경량보안 중요 + 크기 중요
:slim불필요 패키지 제거중간 크기
:bookwormDebian bookworm 기반호환성 우선
로컬 터미널
# 기본 pull (latest 태그)
docker pull nginx

# 특정 버전 지정 (프로덕션 권장)
docker pull nginx:1.25.3-alpine

# 여러 이미지 한 번에 pull
docker pull python:3.11-slim
docker pull postgres:15-alpine
docker pull redis:7-alpine

# pull 후 이미지 목록 확인
docker images
# REPOSITORY   TAG              IMAGE ID       CREATED       SIZE
# nginx        1.25.3-alpine    a3b4c5d6e7f8   2 days ago    42.6MB
# python       3.11-slim        b4c5d6e7f8a9   1 week ago    129MB

# 이미지 삭제
docker rmi nginx:1.25.3-alpine

# 사용하지 않는 이미지 모두 삭제 (dangling images)
docker image prune

# 태그 없는 이미지 포함 모두 삭제 (주의: 신중히)
docker image prune -a
💡개념

docker pull 한 줄이 실제로 하는 일 — 매니페스트부터 union mount까지 5단계

docker pull nginx:1.25-alpine 한 줄. 실행하면 Already existsPull complete가 줄마다 번갈아 뜨고, 수십 MB짜리가 몇 초 만에 끝나기도 합니다. 이 짧은 순간에 클라이언트와 레지스트리 사이에서는 이름 해석 → 매니페스트 조회 → 레이어 대조 → 없는 것만 다운로드 → union mount 구성이 순서대로 일어납니다. 이 흐름을 알면 "pull이 왜 느리지", "왜 실패하지", "왜 어떤 레이어는 안 받지"를 단계로 좁혀 진단할 수 있습니다.

TEXT
[내 PC]  docker pull nginx:1.25-alpine
   │
   ① 이름 해석 + 레지스트리 인증        (docker.io/library/nginx:1.25-alpine 로 확장, 익명 토큰 발급)
   │
   ② 매니페스트 조회                    (태그 → 멀티아치 리스트 → 내 아키텍처(amd64) 매니페스트 선택)
   │    → 매니페스트 = config + 레이어 다이제스트 목록
   │
   ③ 레이어 대조                        (매니페스트의 각 레이어 digest를 로컬과 비교)
   │    → 이미 있는 레이어: "Already exists"
   │    → 없는 레이어만: 다음 단계로
   │
   ④ 없는 레이어만 병렬 다운로드 + 압축 해제  (blob 내려받기 → gzip 해제 → digest 검증)
   │    → "Downloading" → "Extracting" → "Pull complete"
   │
   ⑤ union mount 구성                   (레이어들을 overlay2로 겹쳐 단일 읽기전용 파일시스템)
   ▼
[로컬 저장소]  docker images 에 nginx:1.25-alpine 등장 (공통 레이어는 공유 저장)

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면
① 이름·인증nginxdocker.io/library/nginx:latest로 확장하고 레지스트리에 인증 토큰을 요청. 공개 이미지는 익명 토큰으로 통과프라이빗 이미지에 로그인 없음 → unauthorized: authentication required / 이미지명 오타 → repository does not exist
② 매니페스트 조회태그로 매니페스트(또는 멀티아치 리스트)를 받아 내 OS·아키텍처에 맞는 것을 고름. 매니페스트에 config와 레이어 digest 목록이 들어 있음태그가 레지스트리에 없음 → manifest unknown / 내 아키텍처용 매니페스트 없음 → pull은 되어도 실행 시 exec format error
③ 레이어 대조매니페스트의 각 레이어 digest를 로컬 저장소와 대조해, 이미 있으면 건너뜀같은 베이스를 이미 가졌으면 대부분 Already exists로 즉시 통과 — 막히는 게 아니라 여기서 전송량이 준다
④ 다운로드·해제없는 레이어 blob만 병렬로 내려받아 압축 해제하고, 받은 내용의 digest가 매니페스트 값과 같은지 검증네트워크 끊김 → Downloading에서 멈춤·재시도 / 디스크 부족 → no space left on device / 내용 손상 → unexpected digest(다이제스트 불일치)
⑤ union mount받은 읽기전용 레이어들을 overlay2로 겹쳐 하나의 파일시스템으로 구성. 그 위에 컨테이너 쓰기 레이어가 얹힘스토리지 드라이버 문제 → failed to register layer

"pull 성공"은 세 가지가 모두 참이라는 뜻입니다 — 이름·인증을 통과했고(①), 내 아키텍처용 매니페스트를 확보했고(②), 모든 레이어를 받아 digest까지 검증했다(④). 느리면 대개 ④(다운로드)이고, 실패는 대부분 ①(인증·오타)이나 ②(태그·아키텍처)에서 납니다. Already exists가 많이 뜰수록 ③에서 공통 레이어를 공유해 실제 전송량이 줄어듭니다. 참고로 docker images의 SIZE는 압축 해제 후 레이어 크기의 합이라, ④에서 실제로 내려받는 압축된 blob보다 큽니다 — "60MB 이미지를 받는데 20MB만 전송됐다"가 모순이 아닌 이유입니다.

💡개념

docker build — Dockerfile로 이미지 만들기

서비스를 컨테이너로 배포하려면 결국 Dockerfile을 직접 작성해야 합니다. docker pull로 가져오는 공식 이미지는 서버나 런타임 환경이지, 내 애플리케이션이 담긴 이미지가 아닙니다. Dockerfile이 있어야 '내 코드 + 필요한 런타임 + 설정'을 하나의 이미지로 만들어 어느 서버에서든 동일하게 실행할 수 있습니다. 처음에는 Dockerfile 명령을 순서대로 나열하면 되지만, 빌드 속도를 결정하는 레이어 캐시 전략을 이해해야 실무에서 쓸 수 있는 Dockerfile을 작성할 수 있습니다. 이 ConceptBlock에서는 Dockerfile 작성 방법과 빌드 캐시 활용 전략을 다룹니다.

레이어 캐시 전략 — 의존성 먼저 복사해서 캐시 재사용확대

첫 번째 Dockerfile

Dockerfile은 이미지 빌드 레시피입니다. 각 명령이 하나의 레이어를 만듭니다.

로컬 터미널
# 실습 디렉토리 생성
mkdir -p /tmp/docker-image-lab && cd /tmp/docker-image-lab
Dockerfile
# Dockerfile (파일 이름 그대로 'Dockerfile')

# FROM: 베이스 이미지 지정 (항상 첫 번째 명령)
FROM python:3.11-slim

# WORKDIR: 컨테이너 내부 작업 디렉토리 설정
# 이후 명령들의 기본 경로
WORKDIR /app

# COPY: 호스트의 파일을 컨테이너 안으로 복사
# (빌드 컨텍스트 기준 경로)
COPY requirements.txt .

# RUN: 이미지 빌드 중 실행할 명령
# 새 레이어 생성 — 캐시 활용을 위해 변경 빈도 낮은 것을 앞에
RUN pip install --no-cache-dir -r requirements.txt

# 소스코드는 마지막에 복사 (코드 변경 시 pip 재설치 방지)
COPY . .

# EXPOSE: 문서화 목적 (실제 포트 바인딩은 docker run -p로)
EXPOSE 8000

# CMD: 컨테이너 시작 시 실행할 기본 명령
# exec 형식 (배열) 권장 — 신호를 직접 받을 수 있음
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

실습용 파일 만들기:

로컬 터미널
# requirements.txt 생성
cat > /tmp/docker-image-lab/requirements.txt << 'EOF'
fastapi==0.104.1
uvicorn==0.24.0
EOF

# 간단한 FastAPI 앱 생성
cat > /tmp/docker-image-lab/main.py << 'EOF'
from fastapi import FastAPI
app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello from Docker!", "version": "1.0"}

@app.get("/health")
def health_check():
    return {"status": "ok"}
EOF

# Dockerfile 생성
cat > /tmp/docker-image-lab/Dockerfile << 'EOF'
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
EOF

이미지 빌드:

로컬 터미널
cd /tmp/docker-image-lab

# 기본 빌드
docker build -t myapp:v1.0 .

# 빌드 진행 상황 확인 (각 단계가 레이어)
# Step 1/6 : FROM python:3.11-slim
# Step 2/6 : WORKDIR /app
# Step 3/6 : COPY requirements.txt .
# Step 4/6 : RUN pip install --no-cache-dir -r requirements.txt
# Step 5/6 : COPY . .
# Step 6/6 : CMD ["uvicorn", ...]

# 빌드 결과 확인
docker images myapp

# 실행 테스트
docker run -d --name test-app -p 8000:8000 myapp:v1.0
curl http://localhost:8000
# {"message":"Hello from Docker!","version":"1.0"}

curl http://localhost:8000/health
# {"status":"ok"}

# 정리
docker stop test-app && docker rm test-app

레이어 캐시 활용

Dockerfile 명령 순서 하나로 빌드 시간이 5초와 5분으로 갈립니다. 레이어가 변경되지 않으면 Docker는 이전 빌드의 캐시를 재사용합니다. COPY requirements.txt를 소스코드보다 먼저 두는 이유가 바로 이 캐시 전략 때문입니다.

로컬 터미널
# 소스코드만 변경 후 재빌드
echo 'print("updated")' >> /tmp/docker-image-lab/main.py
docker build -t myapp:v1.1 .

# 출력에서 캐시 활용 확인:
# Step 1/6 : FROM python:3.11-slim
#  ---> Using cache          ← 캐시 사용
# Step 2/6 : WORKDIR /app
#  ---> Using cache          ← 캐시 사용
# Step 3/6 : COPY requirements.txt .
#  ---> Using cache          ← requirements.txt 미변경
# Step 4/6 : RUN pip install ...
#  ---> Using cache          ← 의존성 재설치 안 함!
# Step 5/6 : COPY . .
#  ---> 새로 실행             ← 코드 변경됨

의존성(requirements.txt)을 소스코드보다 먼저 COPY하는 이유가 바로 이 캐시 전략입니다. 코드만 바꿀 때 pip install을 다시 실행하지 않아 빌드가 빠릅니다.

💡개념

docker tag와 docker push — 이미지 버전 관리와 배포

이미지를 빌드했는데 레지스트리에 어떻게 올리고 팀원들이 어떻게 내려받는지 모르면, 결국 docker save로 파일을 만들어 scp로 전송하는 방법을 쓰게 됩니다. 이미지를 팀과 공유하고 CI/CD 파이프라인에서 자동 배포하려면 tag와 push가 필수입니다. 특히 태그를 어떻게 붙이느냐가 운영 환경에서 어떤 버전이 돌고 있는지 추적하는 데 직결됩니다. :latest 태그만 쓰다가 '이 서버에 지금 뭐가 올라가 있지?'를 모르는 상황은 실제로 자주 발생합니다. 이 ConceptBlock에서는 이미지 버전 관리 전략과 레지스트리 push 방법을 다룹니다.

태그 전략 — 버전, Git SHA, 환경별 태그 관리확대

docker tag — 이미지에 이름 붙이기

docker tag는 이미지를 복사하지 않고 같은 레이어를 가리키는 새 이름(참조)을 추가합니다. 하나의 이미지를 레지스트리별로 다른 이름으로 push할 때 씁니다.

Docker
# 빌드 시 바로 태그 지정
docker build -t myapp:v1.0 .

# 기존 이미지에 추가 태그 부여
docker tag myapp:v1.0 myapp:latest
docker tag myapp:v1.0 myapp:stable

# Docker Hub에 push하기 위한 태그 (사용자명/이미지명 형식 필수)
docker tag myapp:v1.0 myusername/myapp:v1.0
docker tag myapp:v1.0 myusername/myapp:latest

# AWS ECR 형식
docker tag myapp:v1.0 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1.0

# GitHub Container Registry 형식
docker tag myapp:v1.0 ghcr.io/myorg/myapp:v1.0

# 모든 태그 확인
docker images myapp
# REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
# myapp        v1.0      a3b4c5d6e7f8   5 minutes ago    145MB
# myapp        latest    a3b4c5d6e7f8   5 minutes ago    145MB  ← 같은 IMAGE ID
# myapp        stable    a3b4c5d6e7f8   5 minutes ago    145MB  ← 같은 IMAGE ID

docker push — 레지스트리에 업로드

push 전에 docker login으로 레지스트리에 인증하고, 이미지 이름이 계정명/이미지명 형식으로 태그되어 있어야 합니다.

로컬 터미널
# Docker Hub 로그인
docker login
# Username: myusername
# Password: ****

# push (태그가 '사용자명/이미지명' 형식이어야 함)
docker push myusername/myapp:v1.0
docker push myusername/myapp:latest

# 특정 레지스트리에 로그인
docker login ghcr.io -u myusername --password-stdin <<< $GITHUB_TOKEN

# AWS ECR 로그인 (AWS CLI 필요)
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin \
  123456789.dkr.ecr.us-east-1.amazonaws.com

# push
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1.0

CI/CD에서의 이미지 버전 관리 패턴

태그 전략이 없으면 '지금 운영에 어떤 버전이 올라가 있지?'라는 질문에 답하기 어렵습니다. 프로덕션에서는 :latest 대신 Git 커밋 해시나 릴리즈 버전을 태그로 사용해 어떤 코드로 빌드된 이미지인지 추적할 수 있게 합니다.

로컬 터미널
# Git 커밋 해시를 태그로 사용 (추적 가능성)
GIT_SHA=$(git rev-parse --short HEAD)
docker build -t myapp:${GIT_SHA} .
docker tag myapp:${GIT_SHA} myapp:latest

# 릴리즈 버전 태그
VERSION="v2.1.3"
docker build -t myapp:${VERSION} .
docker tag myapp:${VERSION} myapp:latest

# 브랜치별 태그
BRANCH=$(git branch --show-current | sed 's/\//-/g')
docker build -t myapp:${BRANCH}-${GIT_SHA} .
1같은 python을 태그별로 받아 이미지 크기·레이어를 직접 비교

이미지 탐색과 빌드, 태그, 관리를 실습합니다. python:3.11/-slim/-alpine을 받아 크기 차이를 눈으로 보고, history·inspect로 레이어와 메타데이터를 확인합니다.

로컬 터미널
# 1. 다양한 이미지 크기 비교
docker pull python:3.11
docker pull python:3.11-slim
docker pull python:3.11-alpine
docker images python
# REPOSITORY   TAG          IMAGE ID       CREATED       SIZE
# python       3.11         a3b4c5...      3 days ago    1.01GB  ← 전체 버전
# python       3.11-slim    b4c5d6...      3 days ago    129MB   ← 불필요 패키지 제거
# python       3.11-alpine  c5d6e7...      2 days ago    51.7MB  ← Alpine 기반

# 2. 이미지 레이어 탐색
docker history python:3.11-slim
docker inspect python:3.11-slim | python3 -c "
import json, sys
data = json.load(sys.stdin)
print('OS:', data[0]['Os'])
print('Architecture:', data[0]['Architecture'])
print('Layers:', len(data[0]['RootFS']['Layers']), 'layers')
"

# 3. 빌드한 이미지 탐색
cd /tmp/docker-image-lab
docker build -t myapp:v1.0 . 2>/dev/null || echo "Already built"

docker inspect myapp:v1.0 | python3 -c "
import json, sys
data = json.load(sys.stdin)
print('Created:', data[0]['Created'])
print('Size:', data[0]['Size'] // 1024 // 1024, 'MB')
print('Cmd:', data[0]['Config']['Cmd'])
print('WorkDir:', data[0]['Config']['WorkingDir'])
print('ExposedPorts:', list(data[0]['Config'].get('ExposedPorts', {}).keys()))
"

# 4. .dockerignore로 빌드 컨텍스트 최적화
cat > /tmp/docker-image-lab/.dockerignore << 'EOF'
.git
__pycache__
*.pyc
*.pyo
.env
.venv
venv/
*.log
EOF

# 빌드 컨텍스트 크기 비교 (dockerignore 효과)
docker build -t myapp:v1.0-clean . 2>&1 | head -5
# Sending build context to Docker daemon  X.XXkB  ← .git 등 제외됨
docker images python
🔍실행 후 확인할 것
  • docker images python의 SIZE 열을 비교한다 — 1.01GB(full) vs 129MB(slim) vs 51.7MB(alpine). 차이가 거의 없으면 같은 태그를 받은 것(태그 오타 확인)
  • docker history 출력에서 RUN 레이어가 큰 용량을 차지하는지 본다 — 한 RUN에 apt 설치+캐시가 함께 있으면 그 레이어가 비대해진다(정리 대상)
  • .dockerignore 적용 후 'Sending build context to Docker daemon' 크기가 줄었는지 확인 — 안 줄면 .dockerignore 위치가 빌드 컨텍스트 루트가 아닌 것
  • 용량만 보고 alpine을 고르기 전에 inspect의 Architecture가 배포 대상과 맞는지 본다 — arm64 이미지를 amd64에 올리면 exec format error가 난다

운영 서버에서 항상 myapp:latest로 배포했습니다. 어느 날 새벽 배포 후 서버가 다운됩니다. 원인은 latest가 가리키는 이미지가 의도치 않게 교체된 것이었습니다.

로컬 터미널
# 문제 상황: latest가 무엇인지 추적 불가
docker pull myapp:latest  # 누가 최근에 push한 이미지
docker run -d myapp:latest  # 뭐가 실행될지 불확실

# 현재 실행 중인 이미지의 실제 digest 확인
docker inspect myapp:latest | grep -i digest
# "Id": "sha256:a3f4b1c2d0e9..."
# 이 ID가 기대하는 버전인지 확인 방법이 없음

# 올바른 접근: 불변(Immutable) 태그 사용

# 빌드 시 버전 태그 + latest 함께 부여
VERSION="v1.2.3"
GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "local")
docker build -t myapp:${VERSION} -t myapp:${GIT_SHA} .

# latest는 편의 참조용으로만 (배포 스크립트에서는 사용 금지)
docker tag myapp:${VERSION} myapp:latest

# 배포 스크립트는 항상 정확한 버전 사용
docker run -d --name api myapp:v1.2.3
# 이렇게 하면 "롤백"도 명확
docker run -d --name api myapp:v1.2.2  # 이전 버전으로 즉시 롤백

# 운영 중인 컨테이너의 정확한 이미지 확인
docker inspect api --format '{{.Config.Image}}'
# myapp:v1.2.3  ← 명확

# digest로 완벽한 불변성 보장 (CI/CD 고급 패턴)
docker pull myapp@sha256:a3f4b1c2d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0

교훈: :latest 태그는 "최신"을 의미하지만 실제로는 "마지막으로 push된 것"입니다. 프로덕션 배포에는 반드시 버전 또는 Git SHA 태그를 사용하고, 롤백 계획을 명확히 세워두세요.

증상

docker images를 실행했더니 이름과 태그가 모두 <none>인 이미지가 수십 개 쌓여 있습니다.

로컬 터미널
docker images
# REPOSITORY   TAG       IMAGE ID       CREATED        SIZE
# myapp        latest    a1b2c3d4e5f6   2 hours ago    512MB
# <none>       <none>    f1e2d3c4b5a6   1 day ago      508MB
# <none>       <none>    9a8b7c6d5e4f   3 days ago     505MB
# <none>       <none>    3c4d5e6f7a8b   5 days ago     501MB

docker system df
# TYPE        TOTAL   ACTIVE   SIZE      RECLAIMABLE
# Images      15      3        8.2GB     6.1GB (74%)  ← 대부분이 dangling

원인

같은 태그(myapp:latest)로 반복 빌드할 때마다 기존 태그가 새 이미지로 이동하고, 이전 이미지는 태그를 잃어 <none>:<none>이 됩니다.

Docker
# 매번 같은 태그로 빌드하면
docker build -t myapp:latest .  # 1차 빌드 → ID: abc123 → myapp:latest
docker build -t myapp:latest .  # 2차 빌드 → ID: def456 → myapp:latest
# abc123은 태그를 잃고 <none>:<none>이 됨

해결

로컬 터미널
# dangling 이미지만 정리 (안전)
docker image prune
# WARNING! This will remove all dangling images.
# Are you sure you want to continue? [y/N] y

# 정리 전 크기 확인 후 정리
docker images -f "dangling=true" -q | wc -l  # 개수 확인
docker image prune -f  # 확인 프롬프트 없이 정리

# 예방 — 빌드 시 버전 태그 추가 (latest와 함께)
docker build -t myapp:$(git rev-parse --short HEAD) -t myapp:latest .

증상

이미지를 pull하려는데 인증 오류가 납니다.

로컬 터미널
docker pull ghcr.io/myorg/myapp:v1.2.3
# Error response from daemon: Head "https://ghcr.io/v2/myorg/myapp/manifests/v1.2.3":
# unauthorized: authentication required

원인 진단

로컬 터미널
# 현재 로그인 상태 확인
cat ~/.docker/config.json | grep -A5 "ghcr.io"
# → 없거나 토큰이 만료된 경우

# 토큰 만료 여부 직접 확인
docker login ghcr.io
# Login Succeeded → 토큰 갱신됨

해결

로컬 터미널
# GitHub Container Registry 로그인
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin

# Docker Hub 로그인
docker login -u myusername
# Password: (입력)

# CI 환경에서는 환경변수로 자동 로그인
docker login ghcr.io \
  -u ${{ github.actor }} \
  -p ${{ secrets.GITHUB_TOKEN }}

# 로그인 후 재시도
docker pull ghcr.io/myorg/myapp:v1.2.3

심화 — 레이어는 '차이'의 적층, 지워도 줄지 않는다

💡개념

심화: 이미지 레이어는 diff의 사슬 — RUN에서 지워도 이미지가 안 줄어드는 이유

앞에서 이미지는 읽기 전용 레이어의 스택이라고 배웠습니다. 여기서 한 걸음 더 들어가면, '레이어가 무엇을 담는가'를 알아야 이미지가 왜 예상보다 크고, 왜 파일을 지워도 안 줄어드는지가 풀립니다.

  • 레이어는 스냅샷이 아니라 diff입니다: 각 레이어는 바로 앞 레이어와 비교해 바뀐 것(추가·수정·삭제)만 담습니다. Dockerfile의 한 줄(RUN·COPY·ADD)이 레이어 하나를 만들고, 최종 이미지 크기는 모든 레이어 diff의 입니다.
  • 나중 레이어의 삭제는 앞 레이어를 줄이지 못합니다: 어떤 RUN에서 500MB 파일을 받고 다음 RUN에서 rm으로 지워도, 앞 레이어에는 그 500MB가 그대로 남고 뒤 레이어에는 '이 파일은 없음'이라는 표시(whiteout)만 추가됩니다. 컨테이너 안에서는 파일이 안 보이는데 이미지 크기는 그대로거나 오히려 조금 더 큽니다. 레이어는 append-only라 과거를 되돌려 지울 수 없습니다.
  • 그래서 정리는 '같은 RUN 안에서' 해야 합니다: 받기→쓰기→지우기를 한 RUN으로 묶으면 그 레이어의 최종 diff에는 결과만 남습니다. apt 캐시(/var/lib/apt/lists/*), 빌드 산출물, 임시 파일은 같은 RUN 끝에서 지웁니다. 더 확실한 방법은 멀티스테이지 빌드로, 무거운 빌드는 빌드 전용 스테이지에서 하고 최종 이미지에는 산출물만 COPY해 무거운 중간 레이어 자체를 이미지에서 제외합니다.
  • docker history로 범인을 짚습니다: 레이어별 크기와 그것을 만든 명령을 보여주므로, 비대한 레이어와 원인 명령을 찾아 위 방법으로 합치거나 멀티스테이지로 옮깁니다.

정리하면 '레이어 = diff'라는 한 가지 사실이 이미지 크기의 거의 모든 함정을 설명합니다. 크기를 줄이려면 '지우는 것'이 아니라 '애초에 그 레이어에 안 남기는 것'이 핵심입니다.

상황: 이미지를 줄이려고 한 RUN에서 대용량 의존성·빌드 캐시를 받아 빌드한 뒤, 별도의 RUN rm -rf로 정리하고 재빌드했습니다. 컨테이너에 들어가 보면 그 파일은 분명히 사라졌는데, docker images의 SIZE는 정리 전과 똑같습니다.

원인: 이미지 레이어는 append-only diff의 사슬입니다. 파일을 받은 RUN이 앞 레이어에 이미 그 용량을 확정했고, 뒤의 rm RUN은 새 레이어에 '삭제됨(whiteout)' 표시만 얹습니다. 앞 레이어의 데이터는 그대로 남아 크기에 계속 포함되고, whiteout 레이어만큼 오히려 커질 수도 있습니다.

진단: docker history 로 레이어별 크기를 봅니다.

로컬 터미널
docker history myapp:latest --no-trunc
# 파일을 받은 RUN 레이어는 여전히 480MB 언저리로 잡혀 있고
# rm -rf 를 한 RUN 레이어는 0B 에 가깝다  ← 이 어긋남이 결정적 증거

컨테이너 안 파일 유무(du -sh)와 이미지 SIZE가 서로 어긋나면 이 문제입니다.

해결: 받기·쓰기·지우기를 하나의 RUN으로 합쳐 그 레이어의 최종 diff에 결과만 남깁니다. 더 확실한 방법은 멀티스테이지 빌드로, 빌드 스테이지에서 무거운 작업을 하고 최종 스테이지에는 필요한 산출물만 COPY해 무거운 중간 레이어 자체를 최종 이미지에서 제외합니다. --squash는 레이어 공유 이점을 잃어 기본 해법이 아니라 특수 상황용입니다.

💼
실무 맥락
현업 패턴

시나리오: 멀티 환경 배포 파이프라인 — 하나의 이미지로 dev/stg/prod 배포

"개발 빌드와 운영 빌드가 달라서 생기는 버그"를 없애는 올바른 Docker 이미지 전략입니다. 이미지는 한 번만 빌드하고, 환경별 설정은 환경변수로 주입합니다.

로컬 터미널
# CI 파이프라인 (GitHub Actions / Jenkins)에서 한 번만 빌드

# 1. 빌드 (환경 독립적, 설정 없음)
VERSION=$(git describe --tags --always)
docker build -t myapp:${VERSION} .

# 2. 레지스트리에 push
docker push myapp:${VERSION}

# --- 배포 단계 (환경마다 다른 설정 주입) ---

# dev 배포
docker run -d \
  --name api-dev \
  -e DATABASE_URL=postgresql://dev-db/myapp_dev \
  -e LOG_LEVEL=debug \
  -e FEATURE_NEW_UI=true \
  myapp:${VERSION}

# staging 배포 (같은 이미지)
docker run -d \
  --name api-stg \
  -e DATABASE_URL=postgresql://stg-db/myapp_stg \
  -e LOG_LEVEL=info \
  -e FEATURE_NEW_UI=true \
  myapp:${VERSION}

# production 배포 (같은 이미지)
docker run -d \
  --name api-prod \
  -e DATABASE_URL=postgresql://prod-db/myapp_prod \
  -e LOG_LEVEL=warn \
  -e FEATURE_NEW_UI=false \
  myapp:${VERSION}

# 이미지 내부에 환경별 설정이 없어야 하는 이유:
# 1. 보안: DB 비밀번호, API 키가 이미지에 포함되면 레지스트리 접근자 모두 노출
# 2. 불변성: "dev에서 테스트한 이미지 = stg에서 테스트한 이미지 = prod에 올라간 이미지"
# 3. 롤백: 이전 버전 태그로 즉시 전환 가능

# 현재 prod에 어떤 버전이 떠 있는지 확인
docker inspect api-prod --format '{{.Config.Image}}'
# myapp:v1.4.2-abc1234

# 버그 발견 시 롤백
docker stop api-prod
docker run -d --name api-prod \
  -e DATABASE_URL=postgresql://prod-db/myapp_prod \
  -e LOG_LEVEL=warn \
  myapp:v1.4.1-def5678  # 이전 버전으로 즉시 복구

실무 포인트: 이 패턴을 "Build once, deploy many"라고 합니다. 이미지를 환경별로 따로 빌드하는 팀은 "스테이징에서 됐는데 운영에서 왜 안 되지?"를 경험합니다. 하나의 이미지에 환경변수로 설정을 주입하는 방식이 이를 방지합니다.

핵심 요약

이 모듈에서 다룬 이미지 관련 명령어를 정리합니다.

명령용도
docker pull 이미지:태그레지스트리에서 이미지 다운로드
docker images로컬 이미지 목록
docker build -t 이름:태그 .Dockerfile로 이미지 빌드
docker tag 원본:태그 새이름:태그이미지에 새 태그 추가
docker push 이름:태그레지스트리에 이미지 업로드
docker history 이미지레이어 히스토리 확인
docker inspect 이미지이미지 상세 정보
docker rmi 이미지이미지 삭제
docker image prune사용하지 않는 이미지 정리

이미지 이름 규칙: [레지스트리/][계정/]이름:태그 프로덕션 원칙: :latest 대신 버전 태그 사용, 하나의 이미지로 모든 환경 배포


명령어·단축키 빠른 참조

이 모듈에서 다룬 이미지 라이프사이클 명령을 자주 쓰는 옵션과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.

명령어/단축키용도자주 쓰는 예
docker pull레지스트리에서 이미지 내려받기docker pull nginx:1.25.3-alpine (버전 고정)
docker images로컬 이미지 목록·크기 비교docker images -qf dangling=true (태그 잃은 이미지)
docker buildDockerfile로 이미지 빌드docker build -t app:1.0 --no-cache .
docker tag같은 이미지에 새 이름(참조) 추가docker tag app:1.0 myuser/app:1.0
docker push레지스트리에 이미지 업로드docker login && docker push myuser/app:1.0
docker history레이어별 크기·생성 명령 확인docker history --no-trunc app:1.0
docker inspect이미지 메타데이터(OS/Arch/레이어)docker inspect -f '{{.Os}}/{{.Architecture}}' app:1.0
docker rmi이미지 삭제docker rmi nginx:1.25.3-alpine
docker image prune미사용(dangling) 이미지 정리docker image prune -f (-a는 태그 없는 것까지)
docker system df이미지·컨테이너 디스크 점유 확인docker system df
docker login레지스트리 인증(push 전 필수)echo $TOKEN | docker login ghcr.io -u USER --password-stdin

관련 모듈로 더 깊이:

다음 모듈에서는 이미지와 컨테이너가 실제로 리눅스 커널 위에서 어떻게 격리되는지 살펴봅니다. Docker 엔진의 내부 구조를 설치 과정과 함께 확인합니다.

지식 확인

퀴즈 — 8문제

Q1

프로덕션 배포용 nginx 이미지를 선택하려 합니다. 이미지 크기를 최소화하고 보안 취약점 노출 면적을 줄이는 것이 목표입니다. `nginx:latest`와 `nginx:alpine` 중 무엇을 선택하고, 그 이유는?

Q2

Docker 이미지의 레이어(Layer) 구조에 대한 설명으로 옳은 것은?

Q3

docker build -t myapp:v1.0 . 명령에서 마지막 '.'의 의미는?

Q4

docker tag myapp:latest myapp:v2.0 명령의 효과는?

Q5

docker push를 실행하기 전에 반드시 필요한 준비는?

Q6

큰 이미지를 docker pull 할 때 일부 레이어에 'Already exists'가 뜨고 일부만 새로 받는 이유는?

Q7

[심화] Dockerfile을 고치지 않고 재빌드했다. RUN 레이어가 실행 없이 캐시로 재사용되는 판정 기준은?

Q8

[심화] 잘 되던 Dockerfile의 RUN apt-get install 줄에 패키지 하나를 추가했더니 빌드가 404 Not Found로 실패한다. docker build --no-cache로는 성공한다. 직접 원인은?

0 / 8 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

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

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

이것도 배워보세요