CI 파이프라인이 처음 도입된 날, 빌드가 3분 만에 끝나야 했는데 18분이 걸렸습니다. 원인은 Dockerfile이었습니다 — 소스 코드 한 줄을 바꿀 때마다 npm install이 처음부터 다시 실행됐습니다.
COPY . .를 의존성 설치보다 먼저 쓴 탓에 레이어 캐시가 매번 깨졌습니다. Dockerfile은 단순한 실행 명세가 아니라 팀의 빌드 속도와 이미지 보안까지 결정하는 설계 문서입니다.
Dockerfile로 애플리케이션 이미지 만들기
CI 파이프라인이 처음 도입된 날, 빌드가 3분 만에 끝나야 했는데 18분이 걸렸다. 원인은 Dockerfile이었다 — 소스 코드 한 줄을 바꿀 때마다 npm install이 처음부터 다시 실행됐다. COPY . .를 package.json 복사보다 먼저 썼기 때문이다. 순서 두 줄을 바꿨더니 캐시가 살아났고 빌드가 2분으로 줄었다. Dockerfile은 이미지를 정의하는 명세서이기도 하지만, 레이어 캐시를 어떻게 설계하느냐에 따라 팀 전체의 배포 속도가 달라진다. "일단 돌아가면 됐지"에서 "왜 이 순서인가"로 넘어가는 것이 이 모듈의 목표다.
지금까지 Docker Hub의 기성 이미지를 사용했습니다. 이제 자신의 애플리케이션을 컨테이너 이미지로 패키징하는 방법을 학습합니다. Dockerfile은 이미지 빌드 과정을 코드로 표현하는 명세서입니다. 잘 작성된 Dockerfile은 빌드가 빠르고, 이미지 크기가 작으며, 보안에 안전합니다.
Dockerfile을 처음 작성하는 것부터, 실무에서 쓰이는 멀티스테이지 패턴과 캐싱 최적화까지 단계적으로 다룹니다.
- 1FROM / RUN / COPY / ADD / ENV / EXPOSE / CMD / ENTRYPOINT 명령어 역할과 차이
- 2레이어 캐싱 원리와 빌드 속도 최적화 — COPY 순서가 중요한 이유
- 3멀티스테이지 빌드(multi-stage build)로 최소 크기 프로덕션 이미지 만들기
- 4.dockerignore로 빌드 컨텍스트에서 불필요한 파일 제외
- 5ENTRYPOINT vs CMD 조합 패턴과 실무 권장 방식
실습 디렉토리만 준비되면 시작할 수 있습니다. 샘플 앱 소스 코드는 실습 단계에서 함께 작성합니다.
docker buildx versionmkdir -p ~/docker-lab && cd ~/docker-lab별도 설치 불필요 — 실습 단계에서 소스 파일을 직접 작성합니다
which nano || which vimDockerfile 핵심 명령어 완전 정복
지금까지는 다른 사람이 만든 이미지를 pull해서 실행했습니다. 팀에 "우리 Python 앱을 컨테이너로 올려달라"는 요청이 왔을 때, 직접 이미지를 만들어야 합니다. 그 이미지를 정의하는 파일이 Dockerfile입니다. FROM부터 CMD까지 각 명령어는 이미지 레이어를 하나씩 쌓는 명령인데, 어떤 명령어를 어떤 순서로 쓰느냐에 따라 빌드가 5초에 끝나기도 하고 5분이 걸리기도 합니다. RUN, COPY, ENV, CMD를 처음 쓸 때 헷갈리는 이유는 각 명령어가 "빌드 타임"에 실행되는지 "런타임"에 영향을 주는지가 다르기 때문입니다. 이 ConceptBlock에서는 Dockerfile에서 가장 자주 쓰이는 핵심 명령어와 각각의 용도를 다룹니다.

Dockerfile 명령어 상세 가이드
FROM — 베이스 이미지 지정
모든 Dockerfile은 FROM으로 시작합니다. 다른 이미지를 베이스로 시작하거나, scratch로 완전히 빈 상태에서 시작할 수 있습니다.
# 특정 버전 태그 사용 (권장)
FROM ubuntu:22.04
# Alpine 기반 경량 이미지
FROM python:3.11-slim-bookworm
# 스크래치에서 시작 (Go 바이너리 등 자급자족 앱용)
FROM scratch
# 멀티스테이지 빌드에서 이전 스테이지 참조
FROM builder AS runtime
RUN — 빌드 시 명령어 실행
이미지 빌드 단계에서 셸 명령어를 실행합니다. 각 RUN은 새 레이어를 생성합니다.
# Shell 형식 (기본 셸 /bin/sh -c 로 실행)
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
&& rm -rf /var/lib/apt/lists/*
# Exec 형식 (셸 없이 직접 실행, 셸 특수문자 해석 없음)
RUN ["apt-get", "install", "-y", "curl"]
레이어 수와 각 레이어의 캐시 유효성은 빌드 속도와 이미지 크기에 직접 영향을 줍니다. 나쁜 패턴과 좋은 패턴을 나란히 보면 차이가 바로 보입니다.
# 나쁜 예: 레이어 낭비 + 캐시 부패 위험
RUN apt-get update
RUN apt-get install -y nginx
RUN apt-get install -y curl
# 좋은 예: 하나의 RUN으로 연결 + 캐시 정리
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY vs ADD — 파일 복사
# COPY: 로컬 파일/디렉토리를 이미지로 복사 (권장)
COPY package.json package-lock.json ./
COPY src/ /app/src/
COPY --chown=node:node . /app
# ADD: COPY의 기능 + URL 다운로드 + tar 자동 압축 해제
# 예측 가능성이 낮아 특별한 이유 없으면 COPY 사용 권장
ADD https://example.com/file.tar.gz /tmp/
ADD ./archive.tar.gz /app/ # tar 자동 해제
# COPY는 빌드 컨텍스트 기준, 절대 경로 불가
COPY ../parent/file.txt /app/ # ❌ 에러
CMD vs ENTRYPOINT — 컨테이너 실행 정의
CMD vs ENTRYPOINT 지시어의 실행 엔진-인자 결합 규칙
Dockerfile을 설계할 때 가장 많이 혼동을 저지르는 CMD와 ENTRYPOINT 지시어는, 컨테이너 기동 시 주입하는 인자(arguments) 처리 방식과 서로 연동하여 다음과 같은 엄격한 지시어 결합 매트릭스 규칙에 따라 최종 동작 프로세스를 형성합니다.
CMD와 ENTRYPOINT 결합 동작 매트릭스:
| Dockerfile 구성 | 컨테이너 기동 명령어 | 최종 실행되는 프로세스 커맨드 | 의미 및 사용 사례 |
|---|---|---|---|
ENTRYPOINT ["echo"] CMD ["Hello"] | docker run my-image | echo Hello | CMD의 값이 ENTRYPOINT 엔진의 디폴트 인자로 자동 결합됨 |
ENTRYPOINT ["echo"] CMD ["Hello"] | docker run my-image World | echo World | docker run 실행 시 지정한 인자(World)가 CMD를 덮어씀 |
CMD ["echo", "Hello"] | docker run my-image | echo Hello | CMD 자체가 메인 실행 프로세스로 동작함 |
CMD ["echo", "Hello"] | docker run my-image hostname | hostname | docker run 시 입력한 대체 커맨드(hostname)가 CMD를 통째 오버라이드 |
- 실무형 백엔드 기동 Dockerfile 예제:
Dockerfile
FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . # 백엔드 기동 엔진을 ENTRYPOINT로 고정하여 임의의 실행 명령어 대체를 방어함 ENTRYPOINT ["node"] # 디폴트 기동 파일을 CMD로 명시하여, 필요시 run 명령 인자로 다른 스크립트 실행 허용 CMD ["dist/main.js"]
# CMD: 기본 실행 명령어 (docker run 인수로 덮어쓰기 가능)
CMD ["node", "server.js"]
CMD ["python", "app.py"]
# ENTRYPOINT: 컨테이너의 실행 파일 (덮어쓰기 어려움)
ENTRYPOINT ["nginx", "-g", "daemon off;"]
# 함께 사용: ENTRYPOINT=실행파일, CMD=기본 인수
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"] # docker run 시 인수 미제공 시 기본값
# docker run myapp --port 9090 → python app.py --port 9090 실행
EXPOSE — 문서화용 포트 선언
# 컨테이너가 리스닝하는 포트 문서화 (실제 포트를 여는 것은 아님)
EXPOSE 8080
EXPOSE 443/tcp
EXPOSE 53/udp
EXPOSE는 포트 바인딩을 자동으로 하지 않습니다. 실제 포트 노출은 docker run -p 옵션이 필요합니다.
ENV — 환경 변수 설정
# 빌드 및 런타임에 유효한 환경 변수
ENV NODE_ENV=production
ENV PORT=8080 \
DATABASE_URL=postgresql://localhost/mydb
# 사용
RUN echo "Environment: $NODE_ENV"
WORKDIR — 작업 디렉토리 설정
# 이후 명령어들의 기본 디렉토리 설정
WORKDIR /app
# WORKDIR이 없으면 RUN cd /app은 다음 명령어에 영향 없음!
# RUN cd /app ❌ 다음 명령어는 여전히 /에서 실행
WORKDIR /app # ✅ 이후 모든 명령어가 /app에서 실행
USER — 실행 사용자 변경
# 비root 사용자로 실행 (보안 권장 사항)
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
# Node.js 공식 이미지에는 node 사용자가 있음
USER node
ARG — 빌드 타임 변수
# 빌드 시 --build-arg로 전달받는 변수 (런타임에는 없음)
ARG APP_VERSION=1.0.0
ARG BUILD_DATE
RUN echo "Building version $APP_VERSION on $BUILD_DATE"
# 빌드 시 사용
# docker build --build-arg APP_VERSION=2.0.0 --build-arg BUILD_DATE=$(date) .
빌드 컨텍스트, .dockerignore, 레이어 캐시 최적화
docker build .을 실행했는데 "Sending build context to Docker daemon 500MB"가 뜨고 빌드가 시작도 하기 전에 수십 초가 흐릅니다. node_modules가 통째로 Docker 데몬에 전송되고 있기 때문입니다. .dockerignore가 없으면 이런 일이 자연스럽게 벌어집니다. 또 다른 문제는 소스 코드 한 줄을 바꿨는데 npm install이 다시 실행되는 것입니다. Dockerfile 명령어 순서가 레이어 캐시를 결정하기 때문에, 순서 하나로 빌드 시간이 10배 차이납니다. CI 파이프라인에서 하루 수십 번 빌드하는 팀에서는 이 차이가 개발 속도를 직접 제한합니다. 이 ConceptBlock에서는 빌드 컨텍스트 개념, .dockerignore 활용, 레이어 캐시 최적화 원칙을 다룹니다.

빌드 컨텍스트(Build Context)
docker build 명령어를 실행할 때 마지막 인수(보통 .)가 빌드 컨텍스트입니다. Docker CLI는 이 디렉토리의 전체 내용을 Docker 데몬에 전송합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part2/exam_7 && cd /tmp/docker/part2/exam_7
docker build -t myapp:1.0 .
# ↑ 빌드 컨텍스트 = 현재 디렉토리
# Sending build context to Docker daemon 1.234GB ← 너무 크면 문제!
.dockerignore — 컨텍스트에서 불필요한 파일 제외
.gitignore와 유사한 문법으로 빌드 컨텍스트에서 제외할 파일을 지정합니다.
# .dockerignore 파일 예시
# 의존성 디렉토리 (컨테이너 내에서 재설치)
node_modules/
vendor/
.venv/
__pycache__/
*.pyc
# 버전 관리 파일
.git/
.gitignore
# 개발 환경 파일
.env
.env.local
*.env
# 테스트 및 CI 파일
.github/
tests/
coverage/
*.test.js
# 빌드 결과물 (소스만 필요)
dist/
build/
target/
# 문서
*.md
docs/
# macOS 시스템 파일
.DS_Store
Thumbs.db
# IDE 설정
.vscode/
.idea/
*.swp
레이어 캐시 최적화 — 올바른 명령어 순서
Docker는 Dockerfile의 각 명령어를 실행할 때 해당 레이어의 캐시가 유효한지 확인합니다. 명령어 또는 관련 파일이 변경되면 그 이후의 모든 레이어 캐시가 무효화됩니다.
# ❌ 최적화 전: 소스코드 변경 시 매번 npm install 재실행
FROM node:20-alpine
WORKDIR /app
COPY . . # 소스 전체 복사 (자주 변경됨)
RUN npm install # 매번 재실행됨 (캐시 무효화)
CMD ["node", "server.js"]
# ✅ 최적화 후: 소스코드 변경 시 npm install 캐시 재사용
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # 의존성 명세 먼저 복사
RUN npm ci # 의존성 설치 (package.json 변경 시만 재실행)
COPY . . # 소스코드 복사 (자주 변경되어도 npm install 캐시 유지)
CMD ["node", "server.js"]
캐시 최적화 원칙:
- 변경 빈도가 낮은 레이어를 위로
- 변경 빈도가 높은 레이어를 아래로
- 의존성 파일(package.json, requirements.txt)을 소스코드보다 먼저 COPY
강제 캐시 무효화
# 특정 ARG 변경으로 캐시 무효화
ARG CACHE_DATE=unknown
RUN curl https://example.com/latest-config > /app/config.json
# 빌드 시
docker build --build-arg CACHE_DATE=$(date) -t myapp .
# 또는 --no-cache 플래그로 전체 캐시 무시
docker build --no-cache -t myapp .
멀티스테이지 빌드 — 이미지 크기 최소화
컴파일 언어나 빌드 도구가 필요한 앱에서 최종 이미지 크기를 크게 줄일 수 있습니다.
# Go 앱 멀티스테이지 빌드 예시
# === 빌드 스테이지 ===
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
# === 런타임 스테이지 ===
FROM scratch # 완전히 비어있는 이미지
COPY --from=builder /src/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/app"]
# 결과: 빌드 이미지 ~300MB → 최종 이미지 ~10MB
# Node.js 앱 멀티스테이지 빌드 예시
# === 의존성 설치 스테이지 ===
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# === 빌드 스테이지 (TypeScript 컴파일 등) ===
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# === 런타임 스테이지 ===
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
목표
간단한 Python FastAPI 앱의 Dockerfile을 작성하고, 레이어 캐시 최적화를 적용하여 빌드합니다.
실습 전 프로젝트 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part1/myapp && cd /tmp/docker/part1/myapp
# 예제 앱 파일 생성
cat > main.py << 'EOF'
from fastapi import FastAPI
import os
app = FastAPI(title="Demo API")
@app.get("/")
def read_root():
return {"message": "Hello from Docker!", "env": os.getenv("APP_ENV", "development")}
@app.get("/health")
def health_check():
return {"status": "healthy"}
EOF
cat > requirements.txt << 'EOF'
fastapi==0.109.0
uvicorn[standard]==0.27.0
EOF
cat > .dockerignore << 'EOF'
__pycache__
*.pyc
*.pyo
.env
.venv
venv/
.git
*.md
.pytest_cache
tests/
EOF
이제 Dockerfile을 작성합니다.
최적화된 Dockerfile 작성
# Dockerfile
FROM python:3.11-slim-bookworm
# 비root 사용자 생성 (보안)
RUN groupadd -r appuser && useradd -r -g appuser appuser
# 작업 디렉토리 설정
WORKDIR /app
# 의존성 파일 먼저 복사 (캐시 최적화)
COPY requirements.txt .
# 의존성 설치 (requirements.txt 변경 시만 재실행)
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# 소스코드 복사 (자주 변경됨)
COPY --chown=appuser:appuser . .
# 비root 사용자로 전환
USER appuser
# 포트 문서화
EXPOSE 8000
# 환경 변수 기본값
ENV APP_ENV=production \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# 애플리케이션 실행
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
빌드 및 실행
# 첫 번째 빌드 (캐시 없음)
docker build -t fastapi-demo:1.0 .
# → 모든 레이어 새로 빌드
# main.py 수정 후 두 번째 빌드 (캐시 활용)
echo "# 수정" >> main.py
docker build -t fastapi-demo:1.1 .
# → requirements.txt COPY, pip install 레이어는 캐시 사용!
# → COPY . . 이후만 재빌드됨
# 빌드 히스토리 확인
docker history fastapi-demo:1.0
# 실행 및 테스트
docker run -d -p 8000:8000 --name fastapi-test fastapi-demo:1.0
# API 테스트
curl http://localhost:8000/
curl http://localhost:8000/health
curl http://localhost:8000/docs # Swagger UI
# 이미지 크기 확인
docker images fastapi-demo
환경 변수 주입 실습
# 런타임에 환경 변수 주입
docker run -d -p 8000:8000 \
-e APP_ENV=staging \
--name fastapi-staging \
fastapi-demo:1.0
# 환경 변수가 적용됐는지 확인
curl http://localhost:8000/
# {"message":"Hello from Docker!","env":"staging"}
- docker build -t fastapi-demo:1.0 . 명령이 에러 없이 완료되는가?
- 두 번째 빌드에서 requirements.txt 관련 레이어가 캐시로 재사용되는가?
- curl http://localhost:8000/health 실행 시 {"status":"healthy"} 응답을 받는가?
- docker images fastapi-demo 출력에서 빌드한 태그와 이미지 크기를 확인했는가?
목표
TypeScript로 작성된 Node.js 앱에 멀티스테이지 빌드를 적용하여 프로덕션용 경량 이미지를 만듭니다.
실습 전 프로젝트 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part1/myapp/node-demo/src && cd /tmp/docker/part1/myapp/node-demo
# 예제 파일 생성
cat > package.json << 'EOF'
{
"name": "node-demo",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3"
}
}
EOF
cat > src/server.ts << 'EOF'
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from TypeScript + Docker!' });
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
EOF
이제 Dockerfile을 작성합니다.
프로젝트 구조
node-demo/
├── src/
│ └── server.ts
├── package.json
├── tsconfig.json
└── Dockerfile
package.json
{
"name": "node-demo",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3"
}
}
src/server.ts
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from TypeScript + Docker!' });
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
멀티스테이지 Dockerfile
# Dockerfile
# ===== 스테이지 1: 의존성 설치 =====
FROM node:20-alpine AS deps
WORKDIR /app
# package*.json만 먼저 복사 (캐시 최적화)
COPY package*.json ./
# 모든 의존성 설치 (devDependencies 포함 — 빌드에 필요)
RUN npm ci
# ===== 스테이지 2: TypeScript 컴파일 =====
FROM node:20-alpine AS builder
WORKDIR /app
# deps 스테이지에서 node_modules 복사
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# TypeScript 컴파일
RUN npm run build
# ===== 스테이지 3: 프로덕션 런타임 =====
FROM node:20-alpine AS runtime
WORKDIR /app
# 보안: 비root 사용자 사용
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
ENV NODE_ENV=production
# 프로덕션 의존성만 설치
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# 컴파일된 JS만 복사 (TypeScript 소스 코드 미포함)
COPY --from=builder /app/dist ./dist
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
빌드 및 크기 비교
# 멀티스테이지 빌드
docker build -t node-demo:multistage .
# 이미지 크기 확인
docker images node-demo
# REPOSITORY TAG IMAGE ID SIZE
# node-demo multistage ... ~120MB (devDeps, TypeScript 소스 없음)
# 단일 스테이지와 비교 (devDependencies 포함)
# → 단일 스테이지: ~400MB
# 레이어 구조 확인
docker history node-demo:multistage
# 실행 테스트
docker run -d -p 3000:3000 --name node-test node-demo:multistage
curl http://localhost:3000/
--target 옵션으로 특정 스테이지만 빌드
# 디버깅용: deps 스테이지까지만 빌드
docker build --target deps -t node-demo:deps-only .
# builder 스테이지까지만 빌드 (CI에서 테스트 실행용)
docker build --target builder -t node-demo:test .
docker run --rm node-demo:test npm test
목표
BuildKit의 고급 기능을 활용하여 빌드 성능을 개선하고, 빌드 과정을 세밀하게 제어합니다.
BuildKit 활성화
BuildKit은 Docker 23.0 이상에서 기본 활성화됩니다. 이전 버전에서는 환경 변수로 활성화합니다.
# 환경 변수로 BuildKit 활성화
export DOCKER_BUILDKIT=1
# 또는 Docker 데몬 설정에서 영구 활성화
# /etc/docker/daemon.json
{
"features": {
"buildkit": true
}
}
주요 빌드 옵션
# 기본 빌드
docker build -t myapp:1.0 .
# 빌드 인수 전달
docker build \
--build-arg APP_VERSION=2.0.0 \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-t myapp:2.0.0 .
# 캐시 무시하고 완전히 새로 빌드
docker build --no-cache -t myapp:fresh .
# 특정 스테이지까지만 빌드 (멀티스테이지)
docker build --target builder -t myapp:builder .
# 다른 위치의 Dockerfile 사용
docker build -f docker/Dockerfile.prod -t myapp:prod .
# 다른 빌드 컨텍스트 사용
docker build -t myapp . --build-context deps=/path/to/deps
# 빌드 시 플랫폼 지정 (크로스 컴파일)
docker build --platform linux/amd64,linux/arm64 -t myapp:multi .
# 빌드 출력 진행률 표시 방식
docker build --progress=plain -t myapp . # 전체 로그 표시
docker build --progress=auto -t myapp . # 기본 (TTY 감지)
BuildKit 캐시 마운트 (RUN --mount)
# pip 캐시를 레이어에 포함하지 않고 캐시 마운트로 활용
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# npm 캐시 마운트
RUN --mount=type=cache,target=/root/.npm \
npm ci
# apt 캐시 마운트
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y curl
빌드 결과 확인
# 이미지 크기 확인
docker images myapp
# 레이어별 크기 확인
docker history myapp:1.0 --no-trunc
# 이미지 취약점 스캔 (Docker Scout)
docker scout cves myapp:1.0
# 이미지 메타데이터 전체 확인
docker inspect myapp:1.0
유용한 Dockerfile 패턴 모음
# 헬스체크 추가
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# 레이블 추가 (이미지 메타데이터)
LABEL maintainer="devteam@example.com" \
version="1.0.0" \
description="My awesome app"
# 볼륨 선언 (데이터 영속성 의도 문서화)
VOLUME ["/app/data", "/app/logs"]
# 빌드 타임과 런타임 변수 조합
ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION
문제 상황
$ docker build -t myapp .
COPY failed: file not found in build context or excluded by .dockerignore: stat config.json: file does not exist
또는
COPY ../shared/utils.py /app/ # Dockerfile에서 상위 디렉토리 참조 시도
# ERROR: failed to solve: failed to read dockerfile: ...
# forbidden path outside the build context
원인 분석
COPY와 ADD 명령어는 빌드 컨텍스트 내의 파일만 참조할 수 있습니다. 빌드 컨텍스트는 docker build 명령어의 마지막 인수(보통 .)로 지정된 디렉토리입니다.
해결 방법
방법 1: 빌드 컨텍스트를 상위 디렉토리로 변경
# 프로젝트 구조:
# /project/
# shared/
# utils.py
# myapp/
# Dockerfile
# main.py
# 방법: 상위 디렉토리를 컨텍스트로 지정, Dockerfile 위치 명시
docker build -f myapp/Dockerfile -t myapp /project/
이제 Dockerfile에서:
COPY shared/utils.py /app/utils.py # 가능해짐
COPY myapp/ /app/
방법 2: 필요한 파일을 빌드 컨텍스트 내로 이동
# shared 파일을 myapp 디렉토리로 복사
cp -r /project/shared /project/myapp/shared/
docker build -t myapp /project/myapp/
방법 3: .dockerignore 확인
.dockerignore가 필요한 파일을 제외하고 있는지 확인합니다.
# .dockerignore에 config.json이 포함되어 있는지 확인
cat .dockerignore | grep config
# 빌드 컨텍스트에 파일이 존재하는지 확인
ls -la config.json
방법 4: BuildKit의 --build-context 옵션 사용
# 추가 컨텍스트 지정 (BuildKit 필요)
docker build \
--build-context shared=/project/shared \
-t myapp .
Dockerfile에서:
COPY --from=shared utils.py /app/utils.py
문제 상황 1: 패키지가 최신화되지 않음 (캐시 부패)
# 이 Dockerfile로 빌드 후 몇 주 후 다시 빌드
RUN apt-get update # 레이어 캐시 재사용됨!
RUN apt-get install -y curl # 오래된 패키지 목록으로 설치됨
해결
# update와 install을 하나의 RUN으로 묶기
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# 특정 패키지 버전 고정 (권장)
RUN apt-get update && apt-get install -y \
curl=7.81.0-1ubuntu1.15 \
&& rm -rf /var/lib/apt/lists/*
문제 상황 2: 소스코드 변경 없는데 캐시 무효화
# 변경사항 없이 빌드했는데도 캐시가 사용되지 않음
docker build -t myapp .
# Step 4/8 : COPY . .
# ---> Using cache ← 예상대로
# Step 5/8 : RUN pip install -r requirements.txt
# ---> Running in ... ← 왜 재실행?!
원인 진단
# .dockerignore 파일에서 제외되지 않은 파일 중
# 빌드마다 변경되는 파일이 있는지 확인
# 예: 빌드 시마다 생성되는 파일들
ls -la __pycache__/ # Python 캐시 파일
ls -la *.log # 로그 파일
ls -la .git/ # Git 인덱스 변경
# .dockerignore에 추가
echo "__pycache__" >> .dockerignore
echo "*.pyc" >> .dockerignore
echo "*.log" >> .dockerignore
문제 상황 3: --no-cache를 써야 하는 시점
# 다음 경우에 --no-cache 사용:
# 1. curl/wget으로 외부 파일을 다운받는 RUN이 있는데 최신 파일이 필요할 때
# 2. apt-get update 캐시가 너무 오래되었을 때
# 3. 캐시 관련 문제를 디버깅할 때
docker build --no-cache -t myapp:fresh .
# 특정 스테이지의 캐시만 무효화 (ARG 활용)
ARG CACHE_BUST=1
RUN wget https://example.com/config.json
# 빌드 시
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .
현업 Dockerfile 리뷰 체크리스트
코드 리뷰에서 Dockerfile을 검토할 때 확인하는 항목들입니다.
보안 체크리스트
# ✅ 정확한 베이스 이미지 버전 고정
FROM python:3.11.7-slim-bookworm # SHA 다이제스트 사용이 더 안전
# FROM python:3.11@sha256:abc123...
# ✅ 비root 사용자로 실행
USER appuser
# ✅ 시크릿 정보를 이미지 레이어에 남기지 않기
# 나쁜 예 — 이미지 레이어에 API 키가 영구적으로 남음
# RUN pip install --index-url https://user:TOKEN@pypi.example.com/simple mypackage
# 좋은 예 — BuildKit secret 마운트 사용
# RUN --mount=type=secret,id=pip_token \
# pip install --index-url https://$(cat /run/secrets/pip_token)@pypi.example.com/simple mypackage
# ✅ 불필요한 패키지 설치 금지
RUN apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
성능 체크리스트
# ✅ 의존성 파일 먼저 COPY
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# ✅ .dockerignore 적용 확인
# ✅ 멀티스테이지로 최종 이미지 크기 최소화
# ✅ HEALTHCHECK 추가 (프로덕션 필수)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Hadolint — Dockerfile 린터
# Hadolint 설치
docker run --rm -i hadolint/hadolint < Dockerfile
# 출력 예시:
# DL3008 warning: Pin versions in apt get install.
# DL3009 info: Delete the apt-get lists after installing something.
# DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments.
이미지 크기 분석 도구
# dive: 레이어별 파일 변경사항 시각적 분석
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest myapp:1.0
실제 팀에서는 CI/CD 파이프라인에 Hadolint와 보안 스캐너(Trivy, Snyk)를 통합하여 안전하지 않은 Dockerfile이 배포되지 않도록 자동화합니다. 또한 베이스 이미지 버전을 주기적으로 업데이트하는 Renovate Bot 같은 도구를 사용해 의존성을 최신 상태로 유지합니다.
ARG vs ENV — 빌드 변수와 런타임 변수의 차이
이미지에 앱 버전을 심어두고 싶은데 ENV를 쓸지 ARG를 쓸지 헷갈립니다. DB 비밀번호를 Dockerfile에 ENV로 박아두면 docker history 명령으로 누구나 읽을 수 있어 보안 감사에서 즉시 문제가 됩니다. 반대로 ARG로 전달한 값은 완성된 이미지에 남지 않기 때문에 런타임에는 접근할 수 없어 "왜 환경변수가 없지?"라는 혼란을 낳습니다. 두 키워드의 차이는 변수가 "빌드 시점에만 존재하는가, 아니면 컨테이너 실행 중에도 유지되는가"입니다. 이것을 구분하지 않으면 시크릿을 이미지에 굽거나, 필요한 값이 런타임에 사라지는 두 가지 실수 중 하나를 하게 됩니다. 이 ConceptBlock에서는 ARG와 ENV의 동작 범위 차이와 올바른 사용 패턴을 다룹니다.

ARG와 ENV의 핵심 차이
ARG: 빌드 시점에만 존재합니다.
docker build --build-arg KEY=VAL 로 주입.
완성된 이미지에는 값이 남지 않습니다.
ENV: 빌드 + 런타임 모두에서 존재합니다.
이미지 레이어에 기록되어 컨테이너 실행 시에도 유효합니다.
docker run -e KEY=VAL 로 런타임에 덮어쓸 수 있습니다.
ARG 사용 — 빌드 인수
# 버전을 빌드 인수로 받아 FROM에서 사용
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# 빌드 시 원하는 버전 지정
# docker build --build-arg NODE_VERSION=18 -t myapp:node18 .
# ARG 기본값 설정
ARG APP_ENV=production
# 빌드 시 ARG 전달
docker build --build-arg NODE_VERSION=18 -t myapp:node18 .
# ARG 값은 완성된 이미지에 안 남음 (보안 이점)
docker inspect myapp:node18 | grep NODE_VERSION # 결과 없음
ENV 사용 — 런타임 환경변수
# 단일 변수
ENV NODE_ENV=production
# 여러 변수 (한 줄로 설정하면 레이어 1개)
ENV PORT=8080 \
LOG_LEVEL=info \
TZ=Asia/Seoul
# ENV 값은 이미지에 기록됨
docker inspect myapp:node18 | grep -A5 '"Env"'
# "NODE_ENV=production",
# "PORT=8080"
# 컨테이너 실행 시 -e 로 덮어쓰기 가능
docker run -e NODE_ENV=development myapp
ARG → ENV 패턴 — 빌드 인수를 런타임 변수로 전달
# 빌드 시 버전 주입 → 런타임에도 접근 가능하게 전달
ARG APP_VERSION=1.0.0
ENV APP_VERSION=${APP_VERSION}
# 이제 컨테이너 안에서 $APP_VERSION 사용 가능
# 또는 docker inspect 로 확인 가능
보안 주의사항 — ENV에 시크릿을 절대 저장하지 말 것
# ❌ 절대 하면 안 됨: 이미지 레이어에 영구히 기록됨
ENV AWS_SECRET_KEY=abc123secret
ENV DB_PASSWORD=mysecretpassword
# ✅ 올바른 방법: 런타임에 -e 옵션으로 주입
# docker run -e DB_PASSWORD=$MY_SECRET myapp
# ✅ 또는 --env-file 사용 (파일은 .gitignore에 추가)
# docker run --env-file .env.prod myapp
docker history myapp 명령으로 레이어를 보면 ENV에 설정된 값이 그대로 노출됩니다. 이미지를 공유하거나 레지스트리에 push하면 누구나 시크릿을 볼 수 있습니다.
Shell form vs Exec form — PID 1과 시그널 처리
배포 중 docker stop으로 컨테이너를 내리는데 매번 10초씩 기다린다면, 그 10초는 graceful shutdown이 아닙니다. Docker가 SIGTERM을 보냈는데 앱이 받지 못해 강제 종료(SIGKILL)가 실행되기까지 기다리는 시간입니다. 처리 중이던 HTTP 요청이 끊기고, 열려 있던 DB 트랜잭션이 롤백됩니다. 원인은 Dockerfile에서 CMD node server.js처럼 Shell form으로 쓴 것입니다. 이 경우 앱이 PID 1이 아닌 /bin/sh의 자식으로 실행되어 시그널을 직접 받지 못합니다. 한 줄에 대괄호 하나의 차이가 운영 중 컨테이너 재시작 시마다 데이터 손실 위험을 만들어냅니다. 이 ConceptBlock에서는 두 형식이 PID 트리를 어떻게 다르게 구성하는지와 그 운영 영향을 다룹니다.

CMD/ENTRYPOINT의 두 가지 작성 방식
같은 명령을 두 가지 형식으로 쓸 수 있습니다. 한 줄짜리 문자열로 쓰는 Shell form과, JSON 배열로 쓰는 Exec form입니다. 겉보기엔 동일해 보이지만 PID 1이 달라지고, 그 차이가 docker stop 동작에 영향을 줍니다.
# Shell form: 문자열로 작성
CMD node server.js
ENTRYPOINT node server.js
# Exec form: JSON 배열로 작성 (권장)
CMD ["node", "server.js"]
ENTRYPOINT ["node", "server.js"]
Shell form의 문제 — PID 1이 /bin/sh
Shell form은 /bin/sh -c "명령어" 방식으로 실행되므로, 컨테이너의 PID 1이 앱이 아닌 sh가 됩니다. 이것이 왜 문제인지 실행 과정을 보면 바로 드러납니다.
Shell form 실행 과정:
Docker → /bin/sh -c "node server.js" 실행
↑
이게 PID 1이 됨!
node는 PID 2 (자식 프로세스)
# Shell form으로 실행된 컨테이너
docker exec my-container ps aux
# PID COMMAND
# 1 /bin/sh -c node server.js ← PID 1 = sh
# 6 node server.js ← PID 2 = 앱
문제는 docker stop 시 SIGTERM이 PID 1(sh)에 전달되는데,
sh는 자식 프로세스에게 신호를 전달하지 않고 그냥 종료됩니다.
docker stop → SIGTERM → /bin/sh 수신 → node에 전달 안 됨
→ 10초 대기 후 SIGKILL
→ node 강제 종료 → 데이터 손실 가능!
Exec form의 장점 — 앱이 PID 1
Exec form 실행 과정:
Docker → ["node", "server.js"] 직접 exec
↑
node가 PID 1이 됨!
# Exec form으로 실행된 컨테이너
docker exec my-container ps aux
# PID COMMAND
# 1 node server.js ← PID 1 = 앱 자체
docker stop → SIGTERM → node 직접 수신 → graceful shutdown 실행 ✓
→ 진행 중인 요청 완료 후 종료
→ 데이터 손실 없음 ✓
ENTRYPOINT + CMD 조합 패턴
# ENTRYPOINT: 실행 파일 고정 (덮어쓰기 어려움)
# CMD: 기본 인수 (docker run 시 쉽게 덮어쓰기 가능)
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]
# 기본 실행: python app.py --port 8080
docker run myapp
# 포트 변경: python app.py --port 9090
docker run myapp --port 9090
# 완전히 다른 명령: python manage.py migrate
docker run --entrypoint python myapp manage.py migrate
실전 권장 패턴
# Node.js 앱
ENTRYPOINT ["node"]
CMD ["server.js"]
# Python 앱
ENTRYPOINT ["python", "-u"]
CMD ["app.py"]
# nginx (공식 이미지에서 사용하는 방식)
CMD ["nginx", "-g", "daemon off;"]
.dockerignore — 빌드 컨텍스트 최적화와 보안

빌드 컨텍스트란?
docker build -t myapp . 에서 마지막 .이 빌드 컨텍스트입니다. Docker는 이 디렉토리 전체를 tar로 묶어 Docker 데몬에 전송합니다. .dockerignore 파일이 없으면 node_modules(수백 MB)도 전송되고, .git 이력도 이미지에 포함될 수 있습니다.
# .dockerignore 없을 때 빌드 컨텍스트 크기 확인
docker build -t myapp . 2>&1 | head -1
# Sending build context to Docker daemon 487MB ← 너무 큼!
# .dockerignore 적용 후
# Sending build context to Docker daemon 1.2MB ← 최적화됨
.dockerignore 필수 패턴
# .dockerignore
# 의존성 디렉토리 (빌드 중 재설치하므로 불필요)
node_modules/
vendor/
__pycache__/
*.pyc
.venv/
# 버전 관리
.git/
.gitignore
.svn/
# 민감한 파일 ← 보안!
.env
.env.*
*.key
*.pem
*.p12
secrets/
credentials.json
# 빌드 아티팩트
dist/
build/
*.log
coverage/
.nyc_output/
# 개발 도구 설정
.DS_Store
.idea/
.vscode/
*.swp
# 테스트
test/
tests/
__tests__/
*.test.js
*.spec.js
보안 실수 방지
.dockerignore가 없으면 .env 파일이 이미지 안에 포함될 수 있습니다.
# ❌ 실수: .dockerignore 없이 빌드하면 .env가 이미지에 포함됨
# Dockerfile에 COPY . . 가 있으면 .env도 복사됨
# 확인: 이미지 안에 .env가 들어갔는지 검사
docker run --rm myapp find / -name ".env" 2>/dev/null
# /app/.env ← 포함되어 있으면 위험!
# ✅ 해결: .dockerignore에 반드시 .env 추가
echo ".env" >> .dockerignore
COPY 범위 최소화와 .dockerignore 함께 사용
# ❌ 나쁜 패턴: 전체 복사 (node_modules도 함께 복사될 수 있음)
COPY . .
# ✅ 좋은 패턴 1: 필요한 파일만 명시
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
COPY public/ ./public/
# ✅ 좋은 패턴 2: 전체 복사 + .dockerignore로 제외
# (위의 .dockerignore 파일이 제대로 설정된 경우)
COPY . .
정리
핵심 Dockerfile 명령어 요약
FROM python:3.11-slim-bookworm # 베이스 이미지
WORKDIR /app # 작업 디렉토리
COPY requirements.txt . # 파일 복사
RUN pip install -r requirements.txt # 빌드 시 명령 실행
COPY . . # 소스 복사
ENV APP_ENV=production # 환경 변수
EXPOSE 8000 # 포트 문서화
USER appuser # 실행 사용자
CMD ["uvicorn", "main:app", "--port", "8000"] # 기본 실행 명령
핵심 빌드 명령어
docker build -t myapp:1.0 . # 기본 빌드
docker build --no-cache -t myapp:fresh . # 캐시 무시
docker build --target builder . # 특정 스테이지까지
docker build -f Dockerfile.prod . # 다른 Dockerfile 사용
다음 챕터 예고
다음 모듈에서는 이미지 운영에서 자주 만나는 정리·태그·아카이빙 문제를 다룹니다. CI 서버 디스크를 안전하게 비우고 롤백 가능한 태그 전략을 설계해봅니다.