Theme:

Dockerfile을 작성할 때 RUN, COPY, CMD를 아무렇게나 나열하면 동작은 하겠지만, 이미지 크기가 불필요하게 커지거나 컨테이너가 시그널을 제대로 못 받는 문제가 생깁니다. 각 명령어가 정확히 어떤 일을 하는지 이해하면 훨씬 효율적인 이미지를 만들 수 있습니다.

FROM — 모든 것의 시작점

Dockerfile은 반드시 FROM으로 시작합니다. 베이스 이미지를 지정하는 명령어입니다.

DOCKERFILE
# 태그를 명시하지 않으면 :latest가 기본값
FROM node:20-alpine

주의할 점

  • 태그를 반드시 명시하세요. FROM node처럼 쓰면 latest를 가져오는데, 빌드 시점마다 다른 버전이 올 수 있습니다.
  • alpine 기반 이미지는 musl libc를 사용하므로 네이티브 바이너리 호환성 문제가 간혹 발생합니다.
  • scratch는 완전히 빈 이미지로, Go 같은 정적 바이너리를 배포할 때 유용합니다.
DOCKERFILE
# 좋은 예: 정확한 버전 + 변형 태그
FROM python:3.12.3-slim-bookworm

# 나쁜 예: latest가 암시적으로 사용됨
FROM python

RUN — 빌드 시점에 명령 실행

RUN은 이미지를 빌드하는 과정에서 명령어를 실행하고, 그 결과를 새 레이어로 저장합니다.

shell form vs exec form

DOCKERFILE
# 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: 셸을 거치지 않고 직접 실행합니다. 환경변수 치환이 안 됩니다.

레이어 최적화

DOCKERFILE
# 나쁜 예: 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

단순하게 호스트의 파일/디렉토리를 이미지로 복사합니다.

DOCKERFILE
COPY package.json package-lock.json ./
COPY src/ ./src/

ADD

COPY의 기능에 추가로 두 가지를 더 지원합니다.

  1. 원격 URL 다운로드 (하지만 권장하지 않음)
  2. tar 아카이브 자동 해제
DOCKERFILE
# 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 옵션

DOCKERFILE
COPY --chown=node:node package.json ./

파일 소유권을 복사 시점에 지정할 수 있어서, 별도의 RUN chown이 필요 없습니다.

CMD vs ENTRYPOINT — 컨테이너 실행 시 동작

이 두 명령어는 혼동하기 쉽지만, 역할이 명확히 다릅니다.

CMD — 기본 실행 명령

DOCKERFILE
# exec form (권장)
CMD ["node", "server.js"]

# shell form
CMD node server.js

docker run 시 인자를 주면 CMD는 완전히 대체됩니다.

BASH
# CMD가 무시되고 /bin/sh가 실행됨
docker run myapp /bin/sh

ENTRYPOINT — 고정 실행 명령

DOCKERFILE
ENTRYPOINT ["node", "server.js"]

docker run 시 인자를 주면 ENTRYPOINT 뒤에 인자로 추가됩니다.

조합 패턴

가장 흔한 패턴은 ENTRYPOINT로 실행 파일을 고정하고, CMD로 기본 인자를 제공하는 것입니다.

DOCKERFILE
ENTRYPOINT ["python", "manage.py"]
CMD ["runserver", "0.0.0.0:8000"]
BASH
# 기본: python manage.py runserver 0.0.0.0:8000
docker run myapp

# CMD 부분만 대체: python manage.py migrate
docker run myapp migrate

exec form을 써야 하는 이유

DOCKERFILE
# 나쁜 예: 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로 불필요한 파일을 제외해야 빌드가 빨라집니다.

TEXT
# .dockerignore
node_modules
.git
.env
*.md
dist
.idea
.vscode

빌드 컨텍스트가 큰 경우

BASH
# 빌드 컨텍스트 크기 확인
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 — 보조 명령어들

DOCKERFILE
# 작업 디렉토리 설정 (없으면 자동 생성)
WORKDIR /app

# 환경변수 설정 (런타임에도 유지됨)
ENV NODE_ENV=production

# 빌드 인자 (빌드 시점에만 사용, 런타임에는 없음)
ARG APP_VERSION=1.0.0

# 문서화 목적의 포트 선언 (실제로 포트를 열지는 않음)
EXPOSE 3000

ENV vs ARG

특성ENVARG
빌드 시 사용OO
런타임 사용OX
docker build --build-argXO
이미지에 저장OX
DOCKERFILE
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine

# ARG는 FROM 이후에 다시 선언해야 사용 가능
ARG APP_ENV=production
ENV APP_ENV=${APP_ENV}

LABEL과 HEALTHCHECK

DOCKERFILE
# 메타데이터 추가
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 예시

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를 사용합니다.
  • CMDENTRYPOINT는 exec form(JSON 배열)으로 작성하여 시그널 전달 문제를 예방합니다.
  • .dockerignore를 작성하여 빌드 컨텍스트를 최소화합니다.
  • 이 기본기를 제대로 잡아두면 멀티스테이지 빌드나 CI/CD 파이프라인으로 확장할 때 훨씬 수월합니다.
댓글 로딩 중...