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와 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) .
docker build 한 줄이 이미지가 되기까지 — 컨텍스트 전송부터 태깅까지 6단계
docker build -t myapp:1.0 . 한 줄. Enter를 누르면 잠시 뒤 docker images에 myapp:1.0이 나타납니다. 이 사이에 빌더는 빌드 컨텍스트 전송 → 베이스 로드 → 명령마다 레이어 생성 → 캐시 조회 → 이미지 조립 → 태그 부여를 순서대로 처리합니다. 이 흐름을 알면 "빌드가 왜 시작도 전에 느리지", "왜 이 줄에서 멈추지", "왜 캐시가 안 먹지"를 단계로 좁혀 진단할 수 있습니다. 멀티스테이지가 여러 FROM을 병렬로 엮는 이야기라면, 여기서는 하나의 빌드가 명령을 한 줄씩 레이어로 굳히는 과정을 봅니다.
docker build -t myapp:1.0 .
│
① 빌드 컨텍스트 전송 (. 디렉토리 → 데몬/빌더, .dockerignore로 필터)
│
② FROM 베이스 이미지 로드 (로컬에 없으면 레지스트리에서 pull)
│
③ 명령마다 레이어 생성 (RUN/COPY → 임시 컨테이너 실행 → 결과를 레이어로 커밋)
│ 이 과정을 명령 줄 수만큼 반복
│
④ 각 명령 전에 캐시 조회 (캐시 키 = 명령 문자열 + 입력 해시)
│ → 히트면 실행 건너뛰고 캐시 레이어 재사용
│ → 미스면 실행하고 그 아래 명령도 전부 재실행
│
⑤ 최종 이미지 조립 (레이어 스택 + CMD·ENV·EXPOSE 등 설정 메타데이터)
│
⑥ 이미지에 태그 부여 (-t myapp:1.0 → 이미지 ID에 이름 참조 연결)
▼
myapp:1.0 (docker images 목록에 등장)
각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:
| 단계 | 하는 일 | 여기서 막히면 |
|---|---|---|
| ① 컨텍스트 전송 | .(빌드 컨텍스트)의 파일을 빌더로 보냄. .dockerignore로 node_modules 등 제외 | 컨텍스트가 과대하면 Sending build context ... 500MB로 빌드 시작 전 수십 초 지연 |
| ② FROM 로드 | 베이스 이미지를 로컬에서 찾고 없으면 pull | 태그 오타·프라이빗 이미지 → manifest unknown / pull access denied |
| ③ 레이어 생성 | 각 RUN·COPY를 임시 컨테이너에서 실행하고 그 diff를 읽기 전용 레이어로 커밋 | RUN이 0이 아닌 코드로 끝남 → returned a non-zero code로 그 줄에서 중단 |
| ④ 캐시 조회 | 명령 문자열 + 입력(COPY는 파일 체크섬 / RUN은 명령 텍스트)으로 캐시 키 계산 | 앞 레이어가 미스나면 아래가 전부 재실행 — COPY . .를 위에 두면 코드 한 줄에 캐시 붕괴 |
| ⑤ 이미지 조립 | 레이어를 스택으로 묶고 CMD·ENV·EXPOSE·WORKDIR을 이미지 config에 기록 | 설정만 바꾸면(예: CMD) 레이어는 재사용되고 config만 갱신됨 |
| ⑥ 태그 부여 | -t 값으로 최종 이미지 ID에 이름:태그 참조를 연결 | -t 생략 시 이름 없이 ID만 남아 docker images에서 <none>으로 보임 |
즉 "빌드 실패·지연"은 세 지점 중 하나입니다 — ①에서 컨텍스트·베이스를 못 가져왔거나(전송 지연·manifest unknown), ③에서 RUN이 실패했거나, ④의 캐시 미스로 느려진 것. docker build --progress=plain으로 각 Step의 CACHED 여부와 실패 줄을 보고 좁힙니다. [3/6] RUN ...에서 멈추면 ③이고, 코드만 바꿨는데 모든 줄이 CACHED 없이 다시 돈다면 ④의 COPY 순서 문제입니다.
빌드 컨텍스트, .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"}
- 먼저 빌드 출력에서 각 Step 번호와 '---> Using cache' 또는 '---> Running' 을 확인한다 — requirements.txt를 변경하지 않았는데 pip install 레이어가 'Running'으로 재실행된다면 COPY . . 가 의존성 설치보다 앞에 있어 소스 변경이 캐시를 무효화하는 것이다
- docker history fastapi-demo:1.0 출력에서 각 레이어 SIZE를 확인한다 — 단일 RUN 명령에 apt-get update && apt-get install && rm -rf /var/lib/apt/lists/* 를 묶지 않으면 캐시 레이어가 별도로 쌓여 이미지 크기가 50MB 이상 불필요하게 커질 수 있다
- docker images 에서 SIZE가 1GB를 초과하면 → 빌드 스테이지 도구(컴파일러, SDK)가 최종 이미지에 포함된 것. 멀티스테이지 빌드로 전환하면 동일 앱 기준 200~400MB까지 줄어드는 경우가 많다
목표
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.tspackage.jsontsconfig.jsonDockerfile
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 .
심화 — 이미지는 레이어(diff)의 스택이다
심화: 지운 파일도 이미지에 남는다 — 레이어와 유니온 파일시스템
"큰 파일을 받아 쓰고 다음 줄에서 지웠으니 이미지는 작겠지"라는 직관은 자주 틀립니다. 그 이유를 알려면 이미지가 실제로 무엇으로 이뤄졌는지를 봐야 합니다.
- 이미지 = 읽기 전용 레이어의 스택: Dockerfile의 각 명령(RUN·COPY·ADD)은 이전 상태 대비 변경분(diff) 하나를 새 레이어로 쌓습니다. 이 레이어들을 overlay2 같은 스토리지 드라이버가 유니온 마운트로 겹쳐 하나의 파일시스템처럼 보여 줍니다.
- 삭제는 용량을 되돌리지 않는다: 앞 RUN에서 받은 200MB 파일은 그 레이어에 이미 기록됩니다. 다음 RUN의
rm은 앞 레이어를 수정하지 못하고, '이 파일은 없음'이라는 삭제 표시(whiteout) 만 새 레이어에 추가합니다. 겹쳐 보면 파일은 사라져 보이지만, 앞 레이어의 실제 용량은 그대로 남습니다. 그래서 받기·압축해제·정리는 반드시 하나의 RUN에서&&로 끝내야 그 레이어의 최종 상태만 남습니다. - 컨테이너 실행 = 읽기 전용 스택 + 얇은 쓰기 레이어: 컨테이너를 띄우면 이 읽기 전용 레이어들 위에 쓰기 가능한 레이어가 하나 얹힙니다. 컨테이너가 기존 파일을 고치면 그 파일만 위 레이어로 복사돼 수정됩니다(Copy-on-Write). 그래서 같은 이미지로 컨테이너 100개를 띄워도 이미지 본체는 한 번만 저장되고, 각자 얇은 쓰기 레이어만 갖습니다.
이 모델을 이해하면 docker history로 어느 레이어가 이미지를 부풀렸는지 읽어 내고, "왜 rm 했는데 안 줄지?"를 스스로 설명할 수 있습니다.
상황: 개발자 노트북에서 docker build를 두 번째로 돌리면 대부분 CACHED로 표시되며 10초 만에 끝납니다. 그런데 같은 Dockerfile인데도 CI 파이프라인에서는 실행할 때마다 모든 레이어를 새로 빌드해 5분 넘게 걸립니다.
원인: 레이어 캐시는 그 빌드를 수행한 데몬의 로컬 저장소에만 쌓입니다. CI 러너는 보통 매 실행마다 깨끗한 상태로 새로 생성되므로, 이전 빌드가 남긴 로컬 캐시가 아예 존재하지 않습니다. 캐시가 '깨진' 것이 아니라 '처음부터 없는' 것이라, 모든 레이어를 다시 빌드합니다.
진단: 빌드 로그에서 CACHED 줄이 하나도 없이 전부 다시 실행되는지 확인합니다. 로컬에서는 캐시가 잘 먹는데 러너에서만 안 먹는다면, 캐시가 러너 밖에 보관되지 않고 있다는 신호입니다.
해결: 캐시를 러너 밖(레지스트리)에 두어야 합니다. 이전에 푸시한 이미지를 --cache-from으로 지정하거나, BuildKit의 registry/inline 캐시(--cache-to, BUILDKIT_INLINE_CACHE)로 원격 캐시를 내려받아 재사용합니다. --no-cache는 오히려 캐시를 끄는 옵션이니 혼동하면 안 되고, 러너에 영속 볼륨을 붙여 로컬 캐시를 유지하는 방법도 있지만 보통은 레지스트리 캐시가 가장 이식성이 좋습니다.
현업 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 사용
명령어·단축키 빠른 참조
이 모듈에서 다룬 docker build 옵션과 이미지 점검 명령을 실전 예와 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
docker build -t | Dockerfile로 이미지 빌드(태그 지정) | docker build -t myapp:1.0 . (마지막 .=빌드 컨텍스트) |
docker build --no-cache | 레이어 캐시 무시하고 새로 빌드 | 외부 파일 최신화·캐시 부패 디버깅 시 |
docker build --target | 멀티스테이지 특정 스테이지까지만 빌드 | docker build --target builder -t myapp:test . |
docker build -f | 다른 경로·이름의 Dockerfile 사용 | docker build -f docker/Dockerfile.prod -t myapp:prod . |
docker build --build-arg | 빌드 타임 변수(ARG) 주입 | docker build --build-arg NODE_VERSION=18 -t myapp . |
docker build --build-arg CACHE_BUST=$(date +%s) | 특정 지점부터 강제 캐시 무효화 | ARG 변경으로 그 아래 레이어만 재빌드 |
docker build --platform | 멀티아키·크로스 빌드 | docker build --platform linux/amd64,linux/arm64 -t myapp:multi . |
docker build --progress=plain | 빌드 전체 로그 표시(디버깅) | 캐시 히트/미스와 RUN 출력 상세 확인 |
docker history | 레이어별 크기·생성 명령 확인 | docker history myapp:1.0 --no-trunc (비대 레이어 추적) |
docker inspect | 이미지 메타데이터(Env·Cmd 등) 확인 | docker inspect myapp:1.0 | grep -A5 '"Env"' |
docker scout cves | 이미지 취약점(CVE) 스캔 | docker scout cves myapp:1.0 |
docker run --rm -i hadolint/hadolint | Dockerfile 린트(정적 검사) | docker run --rm -i hadolint/hadolint < Dockerfile |
관련 모듈로 더 깊이:
- 레이어 캐싱 원리와 커스텀 이미지 레지스트리 태그 전략 — Dockerfile이 만드는 레이어 캐싱 원리와 태그·push/pull 전략
- 실무에 필수적인 멀티 스테이지 빌드와 최적화 기법 — 빌드 환경과 실행 환경을 분리해 이미지를 90% 이상 줄이는 법
- 대규모 빌드 속도를 10배 끌어올리는 캐시 튜닝 가이드 —
docker build캐시를 BuildKit으로 더 빠르게 재사용하는 고급 기법
다음 챕터 예고
다음 모듈에서는 이미지 운영에서 자주 만나는 정리·태그·아카이빙 문제를 다룹니다. CI 서버 디스크를 안전하게 비우고 롤백 가능한 태그 전략을 설계해봅니다.