Dockerfile 작성법 심화 — 명령어별 동작 원리와 주의점
Dockerfile을 작성할 때
RUN,COPY,CMD를 아무렇게나 나열하면 동작은 하겠지만, 이미지 크기가 불필요하게 커지거나 컨테이너가 시그널을 제대로 못 받는 문제가 생깁니다. 각 명령어가 정확히 어떤 일을 하는지 이해하면 훨씬 효율적인 이미지를 만들 수 있습니다.
FROM — 모든 것의 시작점
Dockerfile은 반드시 FROM으로 시작합니다. 베이스 이미지를 지정하는 명령어입니다.
# 태그를 명시하지 않으면 :latest가 기본값
FROM node:20-alpine
주의할 점
- 태그를 반드시 명시하세요.
FROM node처럼 쓰면latest를 가져오는데, 빌드 시점마다 다른 버전이 올 수 있습니다. alpine기반 이미지는musl libc를 사용하므로 네이티브 바이너리 호환성 문제가 간혹 발생합니다.scratch는 완전히 빈 이미지로, Go 같은 정적 바이너리를 배포할 때 유용합니다.
# 좋은 예: 정확한 버전 + 변형 태그
FROM python:3.12.3-slim-bookworm
# 나쁜 예: latest가 암시적으로 사용됨
FROM python
RUN — 빌드 시점에 명령 실행
RUN은 이미지를 빌드하는 과정에서 명령어를 실행하고, 그 결과를 새 레이어로 저장합니다.
shell form vs exec form
# shell form — /bin/sh -c로 감싸서 실행
RUN apt-get update && apt-get install -y curl
# exec form — 직접 실행 (JSON 배열)
RUN ["apt-get", "update"]
- shell form: 환경변수 치환(
$HOME)이 동작합니다. 하지만/bin/sh -c가 PID 1이 되어 시그널 전달에 문제가 생길 수 있습니다. - exec form: 셸을 거치지 않고 직접 실행합니다. 환경변수 치환이 안 됩니다.
레이어 최적화
# 나쁜 예: 3개의 레이어가 생성됨
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# 좋은 예: 1개의 레이어로 합침
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
한 RUN에서 패키지를 설치하고 같은 RUN에서 캐시를 지워야 레이어에 캐시가 남지 않습니다. 별도 RUN으로 삭제해도 이전 레이어에는 이미 저장된 상태입니다.
COPY vs ADD — 파일을 이미지로 가져오기
COPY
단순하게 호스트의 파일/디렉토리를 이미지로 복사합니다.
COPY package.json package-lock.json ./
COPY src/ ./src/
ADD
COPY의 기능에 추가로 두 가지를 더 지원합니다.
- 원격 URL 다운로드 (하지만 권장하지 않음)
- tar 아카이브 자동 해제
# tar 파일 자동 해제
ADD app.tar.gz /opt/app/
# 이것보다는 curl + RUN 조합을 권장
# ADD https://example.com/file.tar.gz /tmp/ ← 캐시 제어가 어려움
RUN curl -L https://example.com/file.tar.gz | tar xz -C /opt/
공식 Docker 문서에서도 단순 파일 복사에는 COPY를 권장합니다. ADD는 의도하지 않은 동작(자동 해제 등)을 유발할 수 있습니다.
--chown 옵션
COPY --chown=node:node package.json ./
파일 소유권을 복사 시점에 지정할 수 있어서, 별도의 RUN chown이 필요 없습니다.
CMD vs ENTRYPOINT — 컨테이너 실행 시 동작
이 두 명령어는 혼동하기 쉽지만, 역할이 명확히 다릅니다.
CMD — 기본 실행 명령
# exec form (권장)
CMD ["node", "server.js"]
# shell form
CMD node server.js
docker run 시 인자를 주면 CMD는 완전히 대체됩니다.
# CMD가 무시되고 /bin/sh가 실행됨
docker run myapp /bin/sh
ENTRYPOINT — 고정 실행 명령
ENTRYPOINT ["node", "server.js"]
docker run 시 인자를 주면 ENTRYPOINT 뒤에 인자로 추가됩니다.
조합 패턴
가장 흔한 패턴은 ENTRYPOINT로 실행 파일을 고정하고, CMD로 기본 인자를 제공하는 것입니다.
ENTRYPOINT ["python", "manage.py"]
CMD ["runserver", "0.0.0.0:8000"]
# 기본: python manage.py runserver 0.0.0.0:8000
docker run myapp
# CMD 부분만 대체: python manage.py migrate
docker run myapp migrate
exec form을 써야 하는 이유
# 나쁜 예: shell form
ENTRYPOINT node server.js
# 실제 실행: /bin/sh -c "node server.js"
# → node가 PID 1이 아니라 SIGTERM을 받지 못함
# 좋은 예: exec form
ENTRYPOINT ["node", "server.js"]
# → node가 PID 1로 실행되어 시그널을 직접 처리
컨테이너가 정상적으로 종료(graceful shutdown)되려면 PID 1 프로세스가 시그널을 받아야 합니다. shell form을 쓰면 /bin/sh가 PID 1이 되어 시그널이 제대로 전달되지 않습니다.
.dockerignore — 빌드 컨텍스트 정리
docker build 명령어를 실행하면, 현재 디렉토리(빌드 컨텍스트)의 모든 파일이 Docker 데몬으로 전송됩니다. .dockerignore로 불필요한 파일을 제외해야 빌드가 빨라집니다.
# .dockerignore
node_modules
.git
.env
*.md
dist
.idea
.vscode
빌드 컨텍스트가 큰 경우
# 빌드 컨텍스트 크기 확인
docker build --no-cache . 2>&1 | head -5
# "Sending build context to Docker daemon 1.5GB" 같은 메시지가 보이면 문제
# .dockerignore 적용 후
# "Sending build context to Docker daemon 15MB"
WORKDIR, ENV, ARG, EXPOSE — 보조 명령어들
# 작업 디렉토리 설정 (없으면 자동 생성)
WORKDIR /app
# 환경변수 설정 (런타임에도 유지됨)
ENV NODE_ENV=production
# 빌드 인자 (빌드 시점에만 사용, 런타임에는 없음)
ARG APP_VERSION=1.0.0
# 문서화 목적의 포트 선언 (실제로 포트를 열지는 않음)
EXPOSE 3000
ENV vs ARG
| 특성 | ENV | ARG |
|---|---|---|
| 빌드 시 사용 | O | O |
| 런타임 사용 | O | X |
| docker build --build-arg | X | O |
| 이미지에 저장 | O | X |
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# ARG는 FROM 이후에 다시 선언해야 사용 가능
ARG APP_ENV=production
ENV APP_ENV=${APP_ENV}
LABEL과 HEALTHCHECK
# 메타데이터 추가
LABEL maintainer="dev@example.com"
LABEL version="1.0"
# 헬스체크 설정
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
HEALTHCHECK를 설정하면 docker ps에서 컨테이너의 건강 상태를 확인할 수 있고, Docker Compose의 depends_on 조건으로도 활용됩니다.
실전 Dockerfile 예시
# === 빌드 스테이지 ===
FROM node:20-alpine AS builder
WORKDIR /app
# 의존성 먼저 설치 (레이어 캐시 활용)
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# 소스 복사 후 빌드
COPY . .
RUN npm run build
# === 런타임 스테이지 ===
FROM node:20-alpine
WORKDIR /app
# 비루트 사용자 설정
RUN addgroup -g 1001 appgroup \
&& adduser -u 1001 -G appgroup -s /bin/sh -D appuser
# 프로덕션 의존성만 설치
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
# 빌드 결과물만 복사
COPY --from=builder /app/dist ./dist
# 소유권 변경
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
ENTRYPOINT ["node"]
CMD ["dist/server.js"]
자주 하는 실수 정리
| 실수 | 왜 문제인가 | 해결 |
|---|---|---|
FROM node (태그 없음) | 빌드마다 다른 버전이 올 수 있음 | 정확한 버전 태그 사용 |
RUN을 줄마다 작성 | 레이어가 불필요하게 많아짐 | &&로 연결 |
ADD로 단순 파일 복사 | 의도치 않은 자동 해제 | COPY 사용 |
CMD에 shell form 사용 | 시그널 전달 문제 | exec form 사용 |
.dockerignore 미작성 | 빌드 컨텍스트가 거대해짐 | 반드시 작성 |
COPY . .을 먼저 실행 | 의존성 캐시가 무효화됨 | 의존성 파일 먼저 복사 |
| 루트 사용자로 실행 | 보안 취약 | USER 지시어 사용 |
정리
FROM에는 반드시 정확한 태그를 명시합니다.RUN은 관련 명령어를&&로 묶어 레이어 수를 줄입니다.- 단순 파일 복사에는
ADD대신COPY를 사용합니다. CMD와ENTRYPOINT는 exec form(JSON 배열)으로 작성하여 시그널 전달 문제를 예방합니다..dockerignore를 작성하여 빌드 컨텍스트를 최소화합니다.- 이 기본기를 제대로 잡아두면 멀티스테이지 빌드나 CI/CD 파이프라인으로 확장할 때 훨씬 수월합니다.